@cullumco/cadence 0.1.4 → 0.1.6

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.
package/dist/posttool.js CHANGED
@@ -4,6 +4,7 @@ import { readFile, writeFile, mkdir } from "node:fs/promises";
4
4
  import { homedir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import { getGitSignal } from "./providers/git.js";
7
+ import { isPaused } from "./config.js";
7
8
  /* ─────────────────────────────────────────────────────────────────────────
8
9
  * PostToolUse adapter — V2 "after-the-fact" refinement, conservative cut.
9
10
  *
@@ -13,22 +14,44 @@ import { getGitSignal } from "./providers/git.js";
13
14
  * event: the repo entering or leaving a merge/rebase conflict — the
14
15
  * strongest debug tell we have, far stronger than music ever was.
15
16
  *
17
+ * Three material events now, same discipline:
18
+ * 1. the repo entering/leaving a merge/rebase conflict (re-observed via git)
19
+ * 2. a streak of destructive git ops — reset --hard / force-push — read off
20
+ * the command string (no tool_response parsing), i.e. thrash.
21
+ * 3. the test suite entering/leaving a failing state — read off the output
22
+ * of test-runner commands, tri-state honest (can't tell → don't update).
23
+ *
16
24
  * 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
25
+ * - fires only after Bash tool calls whose command mentions git or a test run
26
+ * - injects at most once per TRANSITION (conflict edge, thrash threshold,
27
+ * or tests-failing edge), never per tool
19
28
  * - silent in every other case, silent on every error
20
29
  * ───────────────────────────────────────────────────────────────────────── */
21
30
  const TOTAL_BUDGET_MS = 1500;
22
31
  const STATE_FILE = join(homedir(), ".cadence", "workstate.json");
23
32
  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.
33
+ const THRASH_WINDOW_MS = 10 * 60_000; // destructive ops within 10 min count as a streak
34
+ const THRASH_MIN = 2; // 2nd destructive op in the window = thrash
35
+ // Does this command run a test suite? Phrase-based like intent.ts runner
36
+ // names and `npm test`-shaped invocations, not the bare word "test" (which
37
+ // would match `git stash list | grep test`). Pure + exported for tests.
38
+ export function isTestCommand(cmd) {
39
+ return (/\b(?:npm|yarn|pnpm|bun)\s+(?:run\s+)?test\b/.test(cmd) ||
40
+ /\bnode\s+--test\b/.test(cmd) ||
41
+ /\b(?:jest|vitest|mocha|pytest|tape|ava)\b/.test(cmd) ||
42
+ /\bgo\s+test\b/.test(cmd) ||
43
+ /\bcargo\s+test\b/.test(cmd));
44
+ }
45
+ // Gate 1: is this tool call even capable of changing what we observe?
46
+ // Only Bash commands that mention git (conflict/thrash) or run tests —
47
+ // checking after every tool call would betray the silence rule.
27
48
  export function shouldCheck(input) {
28
49
  if (input.tool_name !== "Bash")
29
50
  return false;
30
51
  const cmd = input.tool_input?.command;
31
- return typeof cmd === "string" && /\bgit\b/.test(cmd);
52
+ if (typeof cmd !== "string")
53
+ return false;
54
+ return /\bgit\b/.test(cmd) || isTestCommand(cmd);
32
55
  }
33
56
  // Gate 2: did the observed state actually TRANSITION? Speak only on the edge.
34
57
  // undefined → true first observation reveals a conflict → speak
@@ -50,6 +73,78 @@ export function refineContext(prev, conflicted) {
50
73
  }
51
74
  return null; // first observation of a clean repo: record silently
52
75
  }
76
+ /* Did the test run fail? Tri-state honest, read off the runner's own summary:
77
+ * true a nonzero failure count, or an unambiguous failure marker
78
+ * false an explicit zero-failure count, or passes with no failure marks
79
+ * undefined can't tell → caller must NOT update state (couldn't look ≠ ok)
80
+ * Counts beat markers: "✖ 0 failing" is a pass even though ✖ appears. */
81
+ export function testsFailedFrom(resp) {
82
+ if (resp == null)
83
+ return undefined;
84
+ const text = typeof resp === "string" ? resp : JSON.stringify(resp);
85
+ if (!text)
86
+ return undefined;
87
+ // explicit counts, either word order: "2 failed" / "fail 2" / "failures: 0"
88
+ const counts = [
89
+ ...text.matchAll(/(\d+)\s*(?:tests?\s+)?fail(?:ed|ing|ures?)?\b/gi),
90
+ ...text.matchAll(/\bfail(?:ed|ing|ures?)?[:\s]+(\d+)/gi),
91
+ ].map((m) => Number(m[1]));
92
+ if (counts.some((n) => n > 0))
93
+ return true;
94
+ if (counts.length > 0)
95
+ return false;
96
+ // unambiguous markers (go test FAIL, TAP "not ok", node/jest ✖ lists)
97
+ if (/\bFAIL(?:ED)?\b/.test(text) || /\bnot ok\b/.test(text) || /✖/.test(text))
98
+ return true;
99
+ // passes with no failure marks anywhere
100
+ if (/\b\d+\s+pass(?:ed|ing)?\b/i.test(text) || /\bok\b/.test(text))
101
+ return false;
102
+ return undefined;
103
+ }
104
+ // Same edge contract as refineContext, for the tests-failing state.
105
+ export function refineTests(prev, failing) {
106
+ if (prev === failing)
107
+ return null;
108
+ if (failing) {
109
+ return ("<user_state_update>observed work: the test suite just started failing. " +
110
+ "Read the cadence as debug now — verify before building further, and " +
111
+ "treat the failures as the current ground truth. If the user's words " +
112
+ "clearly mean otherwise, follow their words.</user_state_update>");
113
+ }
114
+ if (prev === true) {
115
+ return ("<user_state_update>observed work: the tests are passing again — drop " +
116
+ "the debug framing and return to the user's prior cadence.</user_state_update>");
117
+ }
118
+ return null; // first observation of a passing suite: record silently
119
+ }
120
+ // Is this command a destructive/undo git op? Just `reset --hard` and a true
121
+ // force-push — NOT --force-with-lease (the safe one), checkout, or restore
122
+ // (too ordinary to read as thrash). Pure + exported for tests.
123
+ export function isThrashCommand(cmd) {
124
+ return (/git\s+reset\s+--hard\b/.test(cmd) ||
125
+ (/git\s+push\b/.test(cmd) && /(--force\b|\s-f\b)/.test(cmd) && !/--force-with-lease\b/.test(cmd)));
126
+ }
127
+ /* Edge-trigger thrash off the rolling window of destructive-op timestamps.
128
+ * Speaks once when the streak first crosses THRASH_MIN inside the window, then
129
+ * stays quiet until the window empties (so it can fire again on a later run).
130
+ * Pure + exported; `times` already includes the current op if it was one. */
131
+ export function refineThrash(times, now, announced) {
132
+ const recent = times.filter((t) => now - t <= THRASH_WINDOW_MS);
133
+ if (recent.length === 0)
134
+ return { message: null, times: recent, announced: false };
135
+ if (recent.length >= THRASH_MIN && !announced) {
136
+ return {
137
+ message: "<user_state_update>observed work: a streak of destructive git ops " +
138
+ "(reset --hard / force-push). Read this as thrash — pause, verify the " +
139
+ "repo state and what's being undone before the next destructive step. " +
140
+ "If the user's words clearly mean otherwise, follow their words." +
141
+ "</user_state_update>",
142
+ times: recent,
143
+ announced: true,
144
+ };
145
+ }
146
+ return { message: null, times: recent, announced };
147
+ }
53
148
  async function loadState() {
54
149
  try {
55
150
  const raw = JSON.parse(await readFile(STATE_FILE, "utf-8"));
@@ -86,6 +181,8 @@ async function readStdin() {
86
181
  }
87
182
  }
88
183
  async function main() {
184
+ if (await isPaused())
185
+ return; // user asked for silence — observe nothing
89
186
  const input = await readStdin();
90
187
  if (!shouldCheck(input))
91
188
  return;
@@ -97,10 +194,30 @@ async function main() {
97
194
  return; // not a repo / git unavailable → nothing to observe
98
195
  const key = input.session_id ?? "default";
99
196
  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() };
197
+ const prev = state[key];
198
+ const now = Date.now();
199
+ const cmd = typeof input.tool_input?.command === "string" ? input.tool_input.command : "";
200
+ // 1. conflict edge (re-observed via git)
201
+ const conflictMsg = refineContext(prev?.conflicted, git.conflicted);
202
+ // 2. tests-failing edge (read off the runner's output; tri-state honest —
203
+ // an unreadable run keeps the previous observation, it never clears it)
204
+ const failed = isTestCommand(cmd) ? testsFailedFrom(input.tool_response) : undefined;
205
+ const testsFailing = failed ?? prev?.testsFailing;
206
+ const testsMsg = failed != null ? refineTests(prev?.testsFailing, failed) : null;
207
+ // 3. thrash threshold (read off the command string)
208
+ const times = prev?.thrashTimes ?? [];
209
+ const nextTimes = isThrashCommand(cmd) ? [...times, now] : times;
210
+ const thrash = refineThrash(nextTimes, now, prev?.thrashAnnounced ?? false);
211
+ state[key] = {
212
+ conflicted: git.conflicted,
213
+ at: now,
214
+ thrashTimes: thrash.times,
215
+ thrashAnnounced: thrash.announced,
216
+ ...(testsFailing != null ? { testsFailing } : {}),
217
+ };
103
218
  await saveState(state);
219
+ // Strongest tell wins: conflict > tests > thrash. At most one message.
220
+ const message = conflictMsg ?? testsMsg ?? thrash.message;
104
221
  if (message) {
105
222
  process.stdout.write(JSON.stringify({
106
223
  hookSpecificOutput: {
@@ -3,7 +3,36 @@ import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  const CADENCE_DIR = join(homedir(), ".cadence");
5
5
  const ACTIVITY_FILE = join(CADENCE_DIR, "activity.json");
6
- export function activityFrom(prompt, lastPromptAt, now) {
6
+ // Rolling window for typing tempo: how many recent prompts we keep, and how
7
+ // far back they still count toward a "rapid-fire" read.
8
+ const WINDOW_MAX = 10;
9
+ const WINDOW_AGE_MS = 10 * 60_000; // 10 min — older prompts are a different sitting
10
+ const BURST_WINDOW_MS = 5 * 60_000; // ≥3 prompts inside 5 min = a burst
11
+ const BURST_MIN_PROMPTS = 3;
12
+ const SHORT_PROMPT = 80; // median chars under this, in a burst → "rapid"
13
+ const LONG_PROMPT = 280; // one prompt over this → "considered"
14
+ /* Tempo from the prompt-rhythm window. Pure + exported for fixture tests.
15
+ * "considered" wins outright (a long prompt is deliberate even mid-burst);
16
+ * otherwise a tight cluster of short prompts reads as "rapid". */
17
+ export function computeTempo(window) {
18
+ if (window.length === 0)
19
+ return undefined;
20
+ const current = window[window.length - 1];
21
+ if (current.len > LONG_PROMPT)
22
+ return "considered";
23
+ const newest = current.at;
24
+ const inBurst = window.filter((m) => newest - m.at <= BURST_WINDOW_MS);
25
+ if (inBurst.length >= BURST_MIN_PROMPTS) {
26
+ const lens = inBurst.map((m) => m.len).sort((a, b) => a - b);
27
+ const median = lens[Math.floor(lens.length / 2)] ?? 0;
28
+ if (median < SHORT_PROMPT)
29
+ return "rapid";
30
+ }
31
+ if (window.length >= 2)
32
+ return "measured";
33
+ return undefined; // not enough rhythm yet to call it
34
+ }
35
+ export function activityFrom(prompt, lastPromptAt, now, recent = [], tempoEnabled = false) {
7
36
  if (prompt == null)
8
37
  return null;
9
38
  const signal = {
@@ -13,9 +42,15 @@ export function activityFrom(prompt, lastPromptAt, now) {
13
42
  if (lastPromptAt != null && Number.isFinite(lastPromptAt)) {
14
43
  signal.minSinceLastPrompt = Math.max(0, Math.round((now - lastPromptAt) / 60000));
15
44
  }
45
+ if (tempoEnabled) {
46
+ const window = [...recent, { at: now, len: prompt.length }].filter((m) => now - m.at <= WINDOW_AGE_MS);
47
+ const tempo = computeTempo(window);
48
+ if (tempo)
49
+ signal.tempo = tempo;
50
+ }
16
51
  return signal;
17
52
  }
18
- export async function getActivitySignal(prompt, now = Date.now()) {
53
+ export async function getActivitySignal(prompt, now = Date.now(), opts = {}) {
19
54
  let state = {};
20
55
  try {
21
56
  state = JSON.parse(await readFile(ACTIVITY_FILE, "utf-8"));
@@ -23,10 +58,16 @@ export async function getActivitySignal(prompt, now = Date.now()) {
23
58
  catch {
24
59
  // no activity file yet
25
60
  }
26
- const signal = activityFrom(prompt, state.lastPromptAt, now);
61
+ const recent = Array.isArray(state.recent) ? state.recent : [];
62
+ const signal = activityFrom(prompt, state.lastPromptAt, now, recent, opts.tempoEnabled);
27
63
  try {
28
64
  await mkdir(CADENCE_DIR, { recursive: true });
29
- await writeFile(ACTIVITY_FILE, JSON.stringify({ lastPromptAt: now }), "utf-8");
65
+ const nextRecent = prompt == null
66
+ ? recent
67
+ : [...recent, { at: now, len: prompt.length }]
68
+ .filter((m) => now - m.at <= WINDOW_AGE_MS)
69
+ .slice(-WINDOW_MAX);
70
+ await writeFile(ACTIVITY_FILE, JSON.stringify({ lastPromptAt: now, recent: nextRecent }), "utf-8");
30
71
  }
31
72
  catch {
32
73
  // activity is best-effort; never let it break the hook
@@ -1,5 +1,5 @@
1
1
  import { exec } from "node:child_process";
2
- import { readFile } from "node:fs/promises";
2
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
3
3
  import { homedir, uptime, loadavg, cpus } from "node:os";
4
4
  import { join } from "node:path";
5
5
  // One-liner shell helper for the best-effort macOS probes. Always resolves
@@ -138,18 +138,41 @@ export async function getFocus(now = new Date()) {
138
138
  }
139
139
  return manual;
140
140
  }
141
+ // Frontmost app — opt-in, macOS, flavor only. Read at UserPromptSubmit, so the
142
+ // terminal/IDE you typed into is usually frontmost; we filter known shells and
143
+ // editors out, so this speaks only when a genuinely different app (a browser,
144
+ // Slack, a PDF) is in front. Flavor for now; a dial nudge stays a candidate.
145
+ const TERMINAL_APPS = /^(Terminal|iTerm2?|Alacritty|kitty|WezTerm|Warp|Hyper|Code|Code - Insiders|Cursor|Windsurf|Electron|Ghostty|Tabby|rio)$/i;
146
+ export async function getFocusedApp(now) {
147
+ if (!now || process.platform !== "darwin")
148
+ return undefined;
149
+ const app = await sh(`osascript -e 'tell application "System Events" to name of first application process whose frontmost is true'`, 700);
150
+ if (!app)
151
+ return undefined;
152
+ const name = app.split("\n")[0]?.trim();
153
+ if (!name || TERMINAL_APPS.test(name))
154
+ return undefined; // you're in your terminal — no news
155
+ return name;
156
+ }
141
157
  // ── mac context: best-effort shell-outs, all flavor (no dial nudges) ─────────
142
- async function getMacContext() {
158
+ async function getMacContext(focusedAppEnabled, wifiEnabled) {
143
159
  if (process.platform !== "darwin")
144
160
  return {};
145
- const [dark, ssid, displays, focus] = await Promise.all([
161
+ const [dark, ssid, displays, focus, focusedApp] = await Promise.all([
146
162
  sh("defaults read -g AppleInterfaceStyle"), // "Dark", or error (=light)
147
- sh("ipconfig getsummary en0 | awk -F ' SSID : ' '/ SSID : / {print $2}'", 700),
163
+ // SSID names your location opt-in (2026-06-11), like everything
164
+ // privacy-adjacent. Off → don't even spawn the probe.
165
+ wifiEnabled
166
+ ? sh("ipconfig getsummary en0 | awk -F ' SSID : ' '/ SSID : / {print $2}'", 700)
167
+ : Promise.resolve(null),
148
168
  // fast display count via AppleScript (~100ms) — NOT system_profiler (1-3s)
149
169
  sh(`osascript -e 'tell application "System Events" to count of desktops'`, 700),
150
170
  getFocus(),
171
+ getFocusedApp(focusedAppEnabled),
151
172
  ]);
152
173
  const ctx = {};
174
+ if (focusedApp)
175
+ ctx.focusedApp = focusedApp;
153
176
  // `defaults read` exits non-zero when the key is unset — which is exactly
154
177
  // what light mode looks like. So error/null ⇒ light, not unknown.
155
178
  ctx.darkMode = dark != null && /dark/i.test(dark);
@@ -179,6 +202,21 @@ function weatherWord(code) {
179
202
  return "snowy";
180
203
  return "stormy";
181
204
  }
205
+ const WEATHER_CACHE_FILE = join(homedir(), ".cadence", "weather-cache.json");
206
+ // Weather barely moves in half an hour, and the hook + stop hook both ask —
207
+ // without a cache that's two Open-Meteo round-trips per turn for one word.
208
+ const WEATHER_CACHE_MS = 30 * 60_000;
209
+ // Pure freshness check, exported for tests: same location, younger than TTL.
210
+ export function weatherCacheFresh(c, lat, lon, now) {
211
+ if (!c || typeof c !== "object")
212
+ return false;
213
+ const w = c;
214
+ return (typeof w.word === "string" &&
215
+ typeof w.at === "number" &&
216
+ w.lat === lat &&
217
+ w.lon === lon &&
218
+ now - w.at <= WEATHER_CACHE_MS);
219
+ }
182
220
  async function getWeather() {
183
221
  let cfg;
184
222
  try {
@@ -190,6 +228,14 @@ async function getWeather() {
190
228
  const loc = cfg.location;
191
229
  if (!loc || typeof loc.lat !== "number" || typeof loc.lon !== "number")
192
230
  return undefined;
231
+ try {
232
+ const cached = JSON.parse(await readFile(WEATHER_CACHE_FILE, "utf-8"));
233
+ if (weatherCacheFresh(cached, loc.lat, loc.lon, Date.now()))
234
+ return cached.word;
235
+ }
236
+ catch {
237
+ // no cache yet → fetch below
238
+ }
193
239
  const ctrl = new AbortController();
194
240
  const timer = setTimeout(() => ctrl.abort(), WEATHER_TIMEOUT_MS);
195
241
  try {
@@ -200,7 +246,19 @@ async function getWeather() {
200
246
  return undefined;
201
247
  const data = (await res.json());
202
248
  const code = data.current?.weather_code;
203
- return typeof code === "number" ? weatherWord(code) : undefined;
249
+ if (typeof code !== "number")
250
+ return undefined;
251
+ const word = weatherWord(code);
252
+ try {
253
+ // best-effort cache write; a miss just means we fetch again next prompt
254
+ await mkdir(join(homedir(), ".cadence"), { recursive: true });
255
+ const cache = { word, at: Date.now(), lat: loc.lat, lon: loc.lon };
256
+ await writeFile(WEATHER_CACHE_FILE, JSON.stringify(cache), "utf-8");
257
+ }
258
+ catch {
259
+ // ignore
260
+ }
261
+ return word;
204
262
  }
205
263
  catch {
206
264
  return undefined;
@@ -209,17 +267,17 @@ async function getWeather() {
209
267
  clearTimeout(timer);
210
268
  }
211
269
  }
212
- export async function getAmbientSignal(now) {
270
+ export async function getEnvironmentSignal(now, opts = {}) {
213
271
  const hour = now.getHours();
214
272
  const vitals = getVitals(); // sync, free
215
273
  // all probes run in parallel; each resolves to a safe default on failure.
216
274
  const [weather, battery, mac] = await Promise.all([
217
275
  getWeather(),
218
276
  getBattery(),
219
- getMacContext(),
277
+ getMacContext(opts.focusedAppEnabled ?? false, opts.wifiEnabled ?? false),
220
278
  ]);
221
279
  return {
222
- source: "ambient",
280
+ source: "environment",
223
281
  partOfDay: partOfDay(hour),
224
282
  dayOfWeek: DAYS[now.getDay()] ?? "",
225
283
  isWeekend: now.getDay() === 0 || now.getDay() === 6,
@@ -233,5 +291,6 @@ export async function getAmbientSignal(now) {
233
291
  displays: mac.displays,
234
292
  network: mac.network,
235
293
  darkMode: mac.darkMode,
294
+ focusedApp: mac.focusedApp,
236
295
  };
237
296
  }
@@ -0,0 +1,77 @@
1
+ import { providerEnabled, providerSetting } from "../config.js";
2
+ import { debug } from "../debug.js";
3
+ /* ─────────────────────────────────────────────────────────────────────────
4
+ * Esoteric flavor — opt-in, render-only. For people who want a little ambient
5
+ * woo in the room. Never moves a dial (the BACKLOG lean): it colors the block,
6
+ * it doesn't steer real work.
7
+ *
8
+ * moon → computed OFFLINE from the date, no API, no dep.
9
+ * horoscope → user sets their sign; daily text via a keyless API, opt-in and
10
+ * fail-silent, exactly like the weather probe. Absent on any
11
+ * hiccup, never throws.
12
+ * ───────────────────────────────────────────────────────────────────────── */
13
+ const HOROSCOPE_TIMEOUT_MS = 900;
14
+ const SYNODIC_MONTH = 29.53058867; // days, new moon → new moon
15
+ const KNOWN_NEW_MOON = Date.UTC(2000, 0, 6, 18, 14) / 1000; // 2000-01-06 reference
16
+ const PHASES = [
17
+ "new moon",
18
+ "waxing crescent",
19
+ "first quarter",
20
+ "waxing gibbous",
21
+ "full moon",
22
+ "waning gibbous",
23
+ "last quarter",
24
+ "waning crescent",
25
+ ];
26
+ const ZODIAC = new Set([
27
+ "aries", "taurus", "gemini", "cancer", "leo", "virgo",
28
+ "libra", "scorpio", "sagittarius", "capricorn", "aquarius", "pisces",
29
+ ]);
30
+ /** Current moon phase as one of the 8 names. Pure + exported for tests. */
31
+ export function moonPhase(now) {
32
+ const days = (now.getTime() / 1000 - KNOWN_NEW_MOON) / 86_400;
33
+ const frac = (((days % SYNODIC_MONTH) + SYNODIC_MONTH) % SYNODIC_MONTH) / SYNODIC_MONTH;
34
+ const idx = Math.floor(frac * 8 + 0.5) % 8;
35
+ return PHASES[idx];
36
+ }
37
+ async function fetchHoroscope(sign) {
38
+ const s = sign.toLowerCase().trim();
39
+ if (!ZODIAC.has(s))
40
+ return undefined; // garbage in → silent, never a guess
41
+ const ctrl = new AbortController();
42
+ const timer = setTimeout(() => ctrl.abort(), HOROSCOPE_TIMEOUT_MS);
43
+ try {
44
+ const url = `https://horoscope-app-api.vercel.app/api/v1/get-horoscope/daily?sign=${s}&day=TODAY`;
45
+ const res = await fetch(url, { signal: ctrl.signal });
46
+ if (!res.ok)
47
+ return undefined;
48
+ const data = (await res.json());
49
+ const text = data.data?.horoscope_data;
50
+ if (!text)
51
+ return undefined;
52
+ return text.length > 160 ? text.slice(0, 159).trimEnd() + "…" : text;
53
+ }
54
+ catch (e) {
55
+ debug("esoteric", `horoscope lookup failed: ${e instanceof Error ? e.message : String(e)}`);
56
+ return undefined;
57
+ }
58
+ finally {
59
+ clearTimeout(timer);
60
+ }
61
+ }
62
+ export async function getEsotericSignal(providers, now = new Date()) {
63
+ const moonOn = providerEnabled(providers, "moon");
64
+ const sign = providerSetting(providers, "horoscope");
65
+ if (!moonOn && typeof sign !== "string")
66
+ return null; // nothing opted in
67
+ const phase = moonOn ? moonPhase(now) : undefined;
68
+ const horoscope = typeof sign === "string" ? await fetchHoroscope(sign) : undefined;
69
+ if (!phase && !horoscope)
70
+ return null;
71
+ return {
72
+ source: "esoteric",
73
+ moonPhase: phase,
74
+ horoscope,
75
+ sign: typeof sign === "string" ? sign.toLowerCase().trim() : undefined,
76
+ };
77
+ }
@@ -5,9 +5,10 @@ import { exec } from "node:child_process";
5
5
  * Cross-platform (git is git everywhere). Reads the repo at `cwd` — which,
6
6
  * when run from the hook, is the project Claude Code is working in.
7
7
  *
8
- * Rendered as flavor for now: commits this hour, dirty files, and whether
9
- * you're mid-merge/rebase (the real debug tell). No dial nudges yet we
10
- * watch the output first, then decide what should steer.
8
+ * Collects commits this hour, dirty files, and whether you're mid-merge/rebase
9
+ * (the real debug tell). LIVE in dial mapping since 2026-06-05: 3+ commits/hr
10
+ * fast pace, mid-conflict verify-first proactivity (see deriveCadence,
11
+ * applied below self-report so the user's explicit word still wins).
11
12
  * ───────────────────────────────────────────────────────────────────────── */
12
13
  const GIT_TIMEOUT_MS = 700;
13
14
  function git(args, cwd) {
@@ -0,0 +1,37 @@
1
+ // Ordered weakest-cue → strongest is not needed; each kind is independent and
2
+ // the first match wins. Order them by how decisive the framing is.
3
+ const PATTERNS = [
4
+ {
5
+ kind: "debug",
6
+ re: /\b(debug(ging)?|stack ?trace|tracebacks?|why (is|are|does|do|won'?t|isn'?t|can'?t)|stuck on|can'?t figure|keeps? (failing|crashing|breaking)|throwing|segfault|regression)\b/i,
7
+ },
8
+ {
9
+ kind: "think",
10
+ re: /\b(thinking through|let'?s think|think about|weigh(ing)? (the|our|up)|trade-?offs?|brainstorm|explore (the )?options|pros and cons|not sure (which|whether|if)|help me decide|which approach|architect(ure|ing)?\b.*\b(should|best)|design (the|a) )\b/i,
11
+ },
12
+ {
13
+ kind: "ship",
14
+ re: /\b(ship it|let'?s ship|ready to ship|send it|just send|locked in|lock(ing)? in|crank(ing)? (out|through)|grind(ing)? (out|through)|knock (this|these|it) out|let'?s go\b|let'?s finish|wrap (this|it) up|push it through)\b/i,
15
+ },
16
+ {
17
+ kind: "focus",
18
+ re: /\b(deep work|heads ?down|focus mode|in the zone|no distractions|need to concentrate)\b/i,
19
+ },
20
+ ];
21
+ /** Detect a single dominant intent kind from a prompt, or null when the
22
+ * wording carries no clear cadence cue. Pure + exported for fixture tests. */
23
+ export function detectPromptIntent(prompt) {
24
+ for (const { kind, re } of PATTERNS) {
25
+ if (re.test(prompt))
26
+ return kind;
27
+ }
28
+ return null;
29
+ }
30
+ export async function getIntentSignal(prompt) {
31
+ if (!prompt)
32
+ return null;
33
+ const kind = detectPromptIntent(prompt);
34
+ if (!kind)
35
+ return null; // no cue → no signal (silent, never a guess)
36
+ return { source: "intent", kind };
37
+ }
@@ -0,0 +1,51 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ /* ─────────────────────────────────────────────────────────────────────────
5
+ * Moon phase — the first esoteric, opt-in provider (see BACKLOG).
6
+ *
7
+ * Pure offline math: phase from days since a known new moon, no API, no
8
+ * platform dependency. OFF by default — it only speaks if the user opted in
9
+ * via ~/.cadence/config.json: { "providers": { "moon": true } }
10
+ *
11
+ * Render-only by design: esoteric signals color the vibe, they never move
12
+ * dials unless the user explicitly maps them (BACKLOG: "vibe-only unless
13
+ * the user maps them").
14
+ * ───────────────────────────────────────────────────────────────────────── */
15
+ const CONFIG_FILE = join(homedir(), ".cadence", "config.json");
16
+ // Mean synodic month. Anchor: the new moon of 2000-01-06 18:14 UTC.
17
+ // Mean-cycle math drifts up to ~14h from true phase — irrelevant at the
18
+ // "waxing gibbous" level of precision we render.
19
+ const SYNODIC_DAYS = 29.53058867;
20
+ const EPOCH_NEW_MOON_MS = Date.UTC(2000, 0, 6, 18, 14);
21
+ const PHASES = [
22
+ "new",
23
+ "waxing crescent",
24
+ "first quarter",
25
+ "waxing gibbous",
26
+ "full",
27
+ "waning gibbous",
28
+ "last quarter",
29
+ "waning crescent",
30
+ ];
31
+ // Pure and clock-injected so it's fixture-testable.
32
+ export function moonPhase(now) {
33
+ const days = (now.getTime() - EPOCH_NEW_MOON_MS) / 86_400_000;
34
+ const fraction = (((days % SYNODIC_DAYS) + SYNODIC_DAYS) % SYNODIC_DAYS) / SYNODIC_DAYS;
35
+ // Disc illumination: 0 at new (fraction 0), 100 at full (fraction 0.5).
36
+ const illumination = Math.round((1 - Math.cos(2 * Math.PI * fraction)) * 50);
37
+ // Eight equal buckets, centered on the cardinal points (new = ±1/16 around 0).
38
+ const idx = Math.floor(((fraction + 1 / 16) % 1) * 8);
39
+ return { phase: PHASES[idx] ?? "new", illumination };
40
+ }
41
+ export async function getMoonSignal(now = new Date()) {
42
+ try {
43
+ const cfg = JSON.parse(await readFile(CONFIG_FILE, "utf-8"));
44
+ if (cfg.providers?.moon !== true)
45
+ return null; // opt-in only, off by default
46
+ return { source: "moon", ...moonPhase(now) };
47
+ }
48
+ catch {
49
+ return null; // no config / bad JSON → simply off, never throw
50
+ }
51
+ }
@@ -3,6 +3,8 @@ import { readFile, writeFile, mkdir } from "node:fs/promises";
3
3
  import { homedir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { tagsToVibe } from "../vibe.js";
6
+ import { getSpotifyNowPlaying } from "./spotify.js";
7
+ import { loadProviders } from "../config.js";
6
8
  import { debug } from "../debug.js";
7
9
  /* ─────────────────────────────────────────────────────────────────────────
8
10
  * Music = identity + vibe. No Spotify Web API, no auth, no Premium.
@@ -63,7 +65,11 @@ export function osascript(script) {
63
65
  });
64
66
  });
65
67
  }
66
- async function getNowPlaying() {
68
+ // macOS now-playing via the desktop apps' scripting interface. Darwin-only —
69
+ // osascript doesn't exist elsewhere, so non-Mac falls through to Spotify.
70
+ async function getLocalNowPlaying() {
71
+ if (process.platform !== "darwin")
72
+ return null;
67
73
  for (const player of PLAYERS) {
68
74
  if (!(await isRunning(player))) {
69
75
  debug("music", `${player} not running`);
@@ -79,6 +85,11 @@ async function getNowPlaying() {
79
85
  }
80
86
  return null;
81
87
  }
88
+ // Local desktop apps first (zero-setup on Mac); the opt-in Spotify token path
89
+ // second, so a Linux/Windows user who supplied creds still gets music.
90
+ async function getNowPlaying(providers) {
91
+ return (await getLocalNowPlaying()) ?? (await getSpotifyNowPlaying(providers));
92
+ }
82
93
  async function loadCache() {
83
94
  try {
84
95
  return JSON.parse(await readFile(CACHE_FILE, "utf-8"));
@@ -166,8 +177,8 @@ async function getTags(artist) {
166
177
  await saveCache(cache);
167
178
  return tags ?? [];
168
179
  }
169
- export async function getMusicSignal() {
170
- const np = await getNowPlaying();
180
+ export async function getMusicSignal(providers) {
181
+ const np = await getNowPlaying(providers ?? (await loadProviders()));
171
182
  if (!np)
172
183
  return null;
173
184
  const tags = await getTags(np.artist);
@@ -179,5 +190,6 @@ export async function getMusicSignal() {
179
190
  player: np.player || undefined,
180
191
  vibe: vibe && vibe.moods.length ? vibe.moods.join(", ") : undefined,
181
192
  energy: vibe?.energy,
193
+ acoustic: vibe?.acoustic,
182
194
  };
183
195
  }
@@ -2,7 +2,12 @@ import { readFile, stat } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  const STATE_FILE = join(homedir(), ".cadence", "state.txt");
5
- export const STALE_AFTER_MS = 4 * 60 * 60 * 1000;
5
+ // 2h, shortened from 4h: a self-report should track the room you're in now, not
6
+ // the one you were in this morning. Cadence nudges you to refresh as it nears
7
+ // expiry (see session-start composeHint).
8
+ export const STALE_AFTER_MS = 2 * 60 * 60 * 1000;
9
+ // When less than this is left, the session greeting invites a refresh.
10
+ export const REFRESH_SOON_MS = 30 * 60 * 1000;
6
11
  export async function getSelfReportSignal() {
7
12
  try {
8
13
  const [text, info] = await Promise.all([readFile(STATE_FILE, "utf-8"), stat(STATE_FILE)]);