@cullumco/cadence 0.1.5 → 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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "cadence",
3
3
  "displayName": "Cadence",
4
- "version": "0.1.5",
4
+ "version": "0.1.6",
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
@@ -67,8 +67,8 @@ deliberate self-report just outranks it.
67
67
  **Signals → dials → a reframe lens.**
68
68
 
69
69
  1. **Signals** — what Cadence can sense right now:
70
- - **ambient** — time of day, day of week, weather (opt-in), battery, machine
71
- uptime/load, dark mode, displays, wifi, Focus/DND. Mostly zero-setup;
70
+ - **environment** — time of day, day of week, weather (opt-in), battery, machine
71
+ uptime/load, dark mode, displays, wifi (opt-in), Focus/DND. Mostly zero-setup;
72
72
  time/day work everywhere, the Mac-context probes are macOS. The one signal
73
73
  that's always there: `context: friday afternoon, rainy, focus on`.
74
74
  (Focus detection reads the DND database directly, so it needs your
@@ -112,7 +112,7 @@ without checking in stays your call, not the soundtrack's.
112
112
  ## Requirements
113
113
 
114
114
  - **Built for the Mac.** The richest ambient probes — music via AppleScript
115
- now-playing, battery, dark mode, displays, wifi, Focus/DND, focused app —
115
+ now-playing, battery, dark mode, displays, wifi (opt-in), Focus/DND, focused app —
116
116
  read the Mac around you. On other platforms Cadence still runs and still
117
117
  moves the dials: prompt intent, self-report, git, time/day, and typing tempo
118
118
  work anywhere, Spotify can be linked for music, and the Mac-only probes
@@ -285,7 +285,7 @@ See [`BACKLOG.md`](BACKLOG.md). Highlights:
285
285
  ## Caveats
286
286
 
287
287
  - **Built for the Mac.** The richest ambient probes (music-via-OS, battery,
288
- dark mode, displays, wifi, Focus, focused app) are macOS. Other platforms
288
+ dark mode, displays, wifi (opt-in), Focus, focused app) are macOS. Other platforms
289
289
  keep the dial-movers — intent, self-report, git, time/day, typing tempo,
290
290
  linked Spotify — and the rest degrades silently.
291
291
  - **Spotify's audio-features API is not used** — Spotify deprecated it for new
package/dist/cli.js CHANGED
@@ -3,7 +3,7 @@ import { writeFile, mkdir, readFile } from "node:fs/promises";
3
3
  import { homedir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { getMusicSignal } from "./providers/music.js";
6
- import { getSelfReportSignal } from "./providers/selfreport.js";
6
+ import { getSelfReportSignal, STALE_AFTER_MS } from "./providers/selfreport.js";
7
7
  import { getEnvironmentSignal } from "./providers/environment.js";
8
8
  import { getGitSignal } from "./providers/git.js";
9
9
  import { getEsotericSignal } from "./providers/esoteric.js";
@@ -17,24 +17,36 @@ const STATE_FILE = join(CADENCE_DIR, "state.txt");
17
17
  const CONFIG_FILE = join(CADENCE_DIR, "config.json");
18
18
  async function cmdReport(args) {
19
19
  if (args.length === 0) {
20
+ // Same TTL the hook applies — printing an expired report as if it were
21
+ // live would contradict what actually gets injected.
22
+ const report = await getSelfReportSignal();
23
+ if (report) {
24
+ const rem = Math.max(0, STALE_AFTER_MS - (Date.now() - report.setAt));
25
+ const h = Math.floor(rem / 3_600_000);
26
+ const m = Math.floor((rem % 3_600_000) / 60_000);
27
+ console.log(`${report.text} (${h > 0 ? `${h}h${String(m).padStart(2, "0")}m` : `${m}m`} left)`);
28
+ return;
29
+ }
20
30
  try {
21
- const text = (await readFile(STATE_FILE, "utf-8")).trim();
22
- console.log(text || "(no state set)");
31
+ const stale = (await readFile(STATE_FILE, "utf-8")).trim();
32
+ console.log(stale
33
+ ? `(last self-report expired — refresh: cadence report "...")`
34
+ : "(no self-report set)");
23
35
  }
24
36
  catch {
25
- console.log("(no state set)");
37
+ console.log("(no self-report set)");
26
38
  }
27
39
  return;
28
40
  }
29
41
  const text = args.join(" ");
30
42
  await mkdir(CADENCE_DIR, { recursive: true });
31
43
  await writeFile(STATE_FILE, text, "utf-8");
32
- console.log(` state set: "${text}"`);
44
+ console.log(` self-report set: "${text}"`);
33
45
  }
34
46
  async function cmdClear() {
35
47
  await mkdir(CADENCE_DIR, { recursive: true });
36
48
  await writeFile(STATE_FILE, "", "utf-8");
37
- console.log(" state cleared");
49
+ console.log(" self-report cleared");
38
50
  }
39
51
  // Collects live signals and renders the exact block the hook would inject,
40
52
  // or null when there's nothing to say. Shared by `test` and the bare command.
@@ -46,6 +58,7 @@ async function buildPreview() {
46
58
  getSelfReportSignal().catch(() => null),
47
59
  getEnvironmentSignal(new Date(), {
48
60
  focusedAppEnabled: providerEnabled(providers, "focusedApp"),
61
+ wifiEnabled: providerEnabled(providers, "wifi"),
49
62
  }).catch(() => null),
50
63
  getGitSignal(process.cwd()).catch(() => null),
51
64
  getEsotericSignal(providers).catch(() => null),
@@ -79,12 +92,15 @@ async function cmdTest() {
79
92
  // The legibility view: every signal Cadence can read — live value, or the
80
93
  // reason it's absent. Unlike `test`, this never goes silent.
81
94
  async function cmdSignals() {
82
- const [music, report, environment, git, providers] = await Promise.all([
95
+ const providers = await loadProviders();
96
+ const [music, report, environment, git] = await Promise.all([
83
97
  getMusicSignal().catch(() => null),
84
98
  getSelfReportSignal().catch(() => null),
85
- getEnvironmentSignal(new Date()).catch(() => null),
99
+ getEnvironmentSignal(new Date(), {
100
+ focusedAppEnabled: providerEnabled(providers, "focusedApp"),
101
+ wifiEnabled: providerEnabled(providers, "wifi"),
102
+ }).catch(() => null),
86
103
  getGitSignal(process.cwd()).catch(() => null),
87
- loadProviders(),
88
104
  ]);
89
105
  console.log("\n" +
90
106
  renderSignalsTable({
package/dist/config.js CHANGED
@@ -24,6 +24,7 @@ const CONFIG_FILE = join(homedir(), ".cadence", "config.json");
24
24
  export const OPT_IN_PROVIDERS = {
25
25
  typingTempo: "prompt rhythm — rapid-fire vs. one long considered prompt → pace",
26
26
  focusedApp: "frontmost non-terminal app (macOS) → flavor in the context line",
27
+ wifi: "wifi network name (macOS) → place context, home vs. office vs. café",
27
28
  moon: "current moon phase (offline) → esoteric flavor",
28
29
  horoscope: "daily horoscope for your sign, e.g. `cadence enable horoscope leo`",
29
30
  };
package/dist/hook.js CHANGED
@@ -35,7 +35,10 @@ async function collectSignals(cwd, prompt, providers) {
35
35
  const [music, report, environment, git, activity, intent, esoteric] = await Promise.allSettled([
36
36
  getMusicSignal(providers),
37
37
  getSelfReportSignal(),
38
- getEnvironmentSignal(new Date(), { focusedAppEnabled: providerEnabled(providers, "focusedApp") }),
38
+ getEnvironmentSignal(new Date(), {
39
+ focusedAppEnabled: providerEnabled(providers, "focusedApp"),
40
+ wifiEnabled: providerEnabled(providers, "wifi"),
41
+ }),
39
42
  getGitSignal(cwd),
40
43
  getActivitySignal(prompt, Date.now(), { tempoEnabled }),
41
44
  getIntentSignal(prompt),
package/dist/posttool.js CHANGED
@@ -14,15 +14,17 @@ import { isPaused } from "./config.js";
14
14
  * event: the repo entering or leaving a merge/rebase conflict — the
15
15
  * strongest debug tell we have, far stronger than music ever was.
16
16
  *
17
- * Two material events now, same discipline:
17
+ * Three material events now, same discipline:
18
18
  * 1. the repo entering/leaving a merge/rebase conflict (re-observed via git)
19
19
  * 2. a streak of destructive git ops — reset --hard / force-push — read off
20
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).
21
23
  *
22
24
  * Discipline (the BACKLOG's hard constraint):
23
- * - fires only after Bash tool calls whose command mentions git
24
- * - injects at most once per TRANSITION (conflict edge, or thrash threshold),
25
- * 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
26
28
  * - silent in every other case, silent on every error
27
29
  * ───────────────────────────────────────────────────────────────────────── */
28
30
  const TOTAL_BUDGET_MS = 1500;
@@ -30,14 +32,26 @@ const STATE_FILE = join(homedir(), ".cadence", "workstate.json");
30
32
  const MAX_SESSIONS = 20;
31
33
  const THRASH_WINDOW_MS = 10 * 60_000; // destructive ops within 10 min count as a streak
32
34
  const THRASH_MIN = 2; // 2nd destructive op in the window = thrash
33
- // Gate 1: is this tool call even capable of changing git conflict state?
34
- // Only Bash commands that mention git Edit/Read/etc. can't start a merge,
35
- // and checking the repo after every tool call would betray the silence rule.
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.
36
48
  export function shouldCheck(input) {
37
49
  if (input.tool_name !== "Bash")
38
50
  return false;
39
51
  const cmd = input.tool_input?.command;
40
- return typeof cmd === "string" && /\bgit\b/.test(cmd);
52
+ if (typeof cmd !== "string")
53
+ return false;
54
+ return /\bgit\b/.test(cmd) || isTestCommand(cmd);
41
55
  }
42
56
  // Gate 2: did the observed state actually TRANSITION? Speak only on the edge.
43
57
  // undefined → true first observation reveals a conflict → speak
@@ -59,6 +73,50 @@ export function refineContext(prev, conflicted) {
59
73
  }
60
74
  return null; // first observation of a clean repo: record silently
61
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
+ }
62
120
  // Is this command a destructive/undo git op? Just `reset --hard` and a true
63
121
  // force-push — NOT --force-with-lease (the safe one), checkout, or restore
64
122
  // (too ordinary to read as thrash). Pure + exported for tests.
@@ -138,10 +196,15 @@ async function main() {
138
196
  const state = await loadState();
139
197
  const prev = state[key];
140
198
  const now = Date.now();
199
+ const cmd = typeof input.tool_input?.command === "string" ? input.tool_input.command : "";
141
200
  // 1. conflict edge (re-observed via git)
142
201
  const conflictMsg = refineContext(prev?.conflicted, git.conflicted);
143
- // 2. thrash threshold (read off the command string)
144
- const cmd = typeof input.tool_input?.command === "string" ? input.tool_input.command : "";
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)
145
208
  const times = prev?.thrashTimes ?? [];
146
209
  const nextTimes = isThrashCommand(cmd) ? [...times, now] : times;
147
210
  const thrash = refineThrash(nextTimes, now, prev?.thrashAnnounced ?? false);
@@ -150,10 +213,11 @@ async function main() {
150
213
  at: now,
151
214
  thrashTimes: thrash.times,
152
215
  thrashAnnounced: thrash.announced,
216
+ ...(testsFailing != null ? { testsFailing } : {}),
153
217
  };
154
218
  await saveState(state);
155
- // A conflict edge is the stronger tell; fall back to thrash. At most one.
156
- const message = conflictMsg ?? thrash.message;
219
+ // Strongest tell wins: conflict > tests > thrash. At most one message.
220
+ const message = conflictMsg ?? testsMsg ?? thrash.message;
157
221
  if (message) {
158
222
  process.stdout.write(JSON.stringify({
159
223
  hookSpecificOutput: {
@@ -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
@@ -155,12 +155,16 @@ export async function getFocusedApp(now) {
155
155
  return name;
156
156
  }
157
157
  // ── mac context: best-effort shell-outs, all flavor (no dial nudges) ─────────
158
- async function getMacContext(focusedAppEnabled) {
158
+ async function getMacContext(focusedAppEnabled, wifiEnabled) {
159
159
  if (process.platform !== "darwin")
160
160
  return {};
161
161
  const [dark, ssid, displays, focus, focusedApp] = await Promise.all([
162
162
  sh("defaults read -g AppleInterfaceStyle"), // "Dark", or error (=light)
163
- 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),
164
168
  // fast display count via AppleScript (~100ms) — NOT system_profiler (1-3s)
165
169
  sh(`osascript -e 'tell application "System Events" to count of desktops'`, 700),
166
170
  getFocus(),
@@ -198,6 +202,21 @@ function weatherWord(code) {
198
202
  return "snowy";
199
203
  return "stormy";
200
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
+ }
201
220
  async function getWeather() {
202
221
  let cfg;
203
222
  try {
@@ -209,6 +228,14 @@ async function getWeather() {
209
228
  const loc = cfg.location;
210
229
  if (!loc || typeof loc.lat !== "number" || typeof loc.lon !== "number")
211
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
+ }
212
239
  const ctrl = new AbortController();
213
240
  const timer = setTimeout(() => ctrl.abort(), WEATHER_TIMEOUT_MS);
214
241
  try {
@@ -219,7 +246,19 @@ async function getWeather() {
219
246
  return undefined;
220
247
  const data = (await res.json());
221
248
  const code = data.current?.weather_code;
222
- 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;
223
262
  }
224
263
  catch {
225
264
  return undefined;
@@ -235,7 +274,7 @@ export async function getEnvironmentSignal(now, opts = {}) {
235
274
  const [weather, battery, mac] = await Promise.all([
236
275
  getWeather(),
237
276
  getBattery(),
238
- getMacContext(opts.focusedAppEnabled ?? false),
277
+ getMacContext(opts.focusedAppEnabled ?? false, opts.wifiEnabled ?? false),
239
278
  ]);
240
279
  return {
241
280
  source: "environment",
@@ -15,7 +15,7 @@ function ttlLeft(setAt, now) {
15
15
  const m = Math.floor((rem % 3_600_000) / 60_000);
16
16
  return h > 0 ? `${h}h${String(m).padStart(2, "0")}m left` : `${m}m left`;
17
17
  }
18
- function environmentRows(a, platform) {
18
+ function environmentRows(a, platform, providers) {
19
19
  if (!a)
20
20
  return [top("environment", "— unavailable")];
21
21
  const mac = platform === "darwin";
@@ -46,9 +46,11 @@ function environmentRows(a, platform) {
46
46
  : row("displays", String(a.displays), "(hidden: only shows >1)"));
47
47
  lines.push(!mac
48
48
  ? row("wifi", macNote)
49
- : a.network
50
- ? row("wifi", JSON.stringify(a.network))
51
- : row("wifi", "— unavailable"));
49
+ : !providerEnabled(providers, "wifi")
50
+ ? row("wifi", "— off (run: cadence enable wifi)")
51
+ : a.network
52
+ ? row("wifi", JSON.stringify(a.network))
53
+ : row("wifi", "— unavailable (macOS may require Location Services)"));
52
54
  lines.push(a.uptimeHours == null
53
55
  ? row("uptime", "— unavailable")
54
56
  : a.uptimeHours >= 12
@@ -123,7 +125,7 @@ function optInFlavorRows(providers, environment) {
123
125
  export function renderSignalsTable(raw) {
124
126
  const providers = raw.providers ?? {};
125
127
  return [
126
- ...environmentRows(raw.environment, raw.platform),
128
+ ...environmentRows(raw.environment, raw.platform, raw.providers ?? {}),
127
129
  ...musicRows(raw.music, providers),
128
130
  reportRow(raw.report, raw.now),
129
131
  intentRow(),
package/dist/vibe.js CHANGED
@@ -113,7 +113,7 @@ const GENRE_AFFECT = {
113
113
  opera: { energy: 0.5, valence: 0.45, acoustic: 0.9, moods: ["epic"] },
114
114
  choral: { energy: 0.3, valence: 0.5, acoustic: 0.95, moods: ["ethereal", "epic"] },
115
115
  // ── low energy, low valence → pace low; dark moods are render-only ───
116
- // (valence currently moves NO dial — see BACKLOG "energyToMode boundary")
116
+ // (valence currently moves NO dial — see BACKLOG "Valence boundary")
117
117
  blues: { energy: 0.45, valence: 0.35, acoustic: 0.5, moods: ["sad", "dark"] },
118
118
  goth: { energy: 0.55, valence: 0.3, acoustic: 0.2, moods: ["dark"] },
119
119
  gothic: { energy: 0.55, valence: 0.3, acoustic: 0.2, moods: ["dark"] },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cullumco/cadence",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
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": {