@cullumco/cadence 0.1.1 → 0.1.2

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.
@@ -9,7 +9,7 @@
9
9
  {
10
10
  "name": "cadence",
11
11
  "displayName": "Cadence",
12
- "description": "Agents that read the room: embodied state, cadence dials, and conservative finish-line guardrails for Claude Code.",
12
+ "description": "Agents that read the room: embodied state, cadence dials, and conservative finish-line guardrails for Claude Code. macOS-only alpha.",
13
13
  "source": {
14
14
  "source": "npm",
15
15
  "package": "@cullumco/cadence"
@@ -21,7 +21,7 @@
21
21
  "agents",
22
22
  "claude-code"
23
23
  ],
24
- "homepage": "https://github.com/cullumco/cadence",
24
+ "homepage": "https://cullumco.github.io/cadence/",
25
25
  "repository": "https://github.com/cullumco/cadence",
26
26
  "license": "MIT"
27
27
  }
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "cadence",
3
3
  "displayName": "Cadence",
4
- "version": "0.1.1",
5
- "description": "Ambient context for Claude Code: embodied signals, cadence dials, and finish-line guardrails.",
4
+ "version": "0.1.2",
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",
8
8
  "url": "https://cullum.co"
9
9
  },
10
- "homepage": "https://github.com/cullumco/cadence",
10
+ "homepage": "https://cullumco.github.io/cadence/",
11
11
  "repository": "https://github.com/cullumco/cadence",
12
12
  "license": "MIT",
13
13
  "keywords": [
package/README.md CHANGED
@@ -9,6 +9,9 @@ listening to, what you told it, how you want it to respond — into every prompt
9
9
  then asks Claude to *read your prompt through that lens*. The agent stops being
10
10
  deaf to the room.
11
11
 
12
+ **macOS-only (alpha).** Most signals read the Mac around you; other platforms
13
+ degrade to self-report + dials + time/git.
14
+
12
15
  A [Cullum&Co](https://cullum.co) project.
13
16
 
14
17
  ## What it does
@@ -34,14 +37,38 @@ It doesn't constrain the agent or rewrite your prompt — it gives the model the
34
37
  context your words are missing, and a lens for reading them. The lens always
35
38
  defers to what you actually typed.
36
39
 
40
+ ## Same prompt, different room
41
+
42
+ > "how should I structure the retry logic?"
43
+
44
+ **Without Cadence** — every prompt reads the same. You get the survey: four
45
+ options, a trade-off table, and a closing "Would you like me to implement one
46
+ of these?"
47
+
48
+ **With Cadence, shipping cadence** — hardcore at 3 commits/hr, state set to
49
+ `"ship mode"` → `{ pace=fast posture=decisive proactivity=act-freely }`. You
50
+ get the call, made: exponential backoff with jitter, three attempts, here's
51
+ the diff, tests pass.
52
+
53
+ **With Cadence, thinking cadence** — ambient music, state set to
54
+ `"thinking through tradeoffs"` → `{ pace=deliberate posture=exploratory }`.
55
+ You get the options laid out patiently, trade-offs actually explored, no
56
+ pressure to pick one yet.
57
+
58
+ Same words. The room around them changed, and the agent finally saw it.
59
+
37
60
  ## How it works
38
61
 
39
62
  **Signals → dials → a reframe lens.**
40
63
 
41
64
  1. **Signals** — what Cadence can sense right now:
42
65
  - **ambient** — time of day, day of week, weather (opt-in), battery, machine
43
- uptime/load, dark mode, displays, wifi. Mostly zero-setup, cross-platform.
44
- The one signal that's always there: `context: friday afternoon, rainy`.
66
+ uptime/load, dark mode, displays, wifi, Focus/DND. Mostly zero-setup;
67
+ time/day work everywhere, the Mac-context probes are macOS. The one signal
68
+ that's always there: `context: friday afternoon, rainy, focus on`.
69
+ (Focus detection reads the DND database directly, so it needs your
70
+ terminal to have Full Disk Access — `cadence signals` tells you if it
71
+ doesn't.)
45
72
  - **git** — commits this hour, dirty files, mid-merge/rebase, read from the
46
73
  project you're in: `git: 6 dirty, mid-conflict`. Cross-platform.
47
74
  - **activity** — prompt length and minutes since your last prompt, read from
@@ -69,9 +96,11 @@ express.
69
96
 
70
97
  ## Requirements
71
98
 
72
- - **macOS** (the now-playing reader uses AppleScript against Spotify.app /
73
- Music.app). The self-report and dials work everywhere; only the music signal
74
- is macOS-only.
99
+ - **macOS.** Cadence is mac-only for the alpha: music (AppleScript
100
+ now-playing), battery, dark mode, displays, wifi, and Focus/DND all read the
101
+ Mac around you. On other platforms it still runs — self-report, dials,
102
+ time/day, and git work anywhere, the rest degrade silently — but the product
103
+ is built for the Mac.
75
104
  - **Node 20+**
76
105
  - Claude Code for the alpha adapter
77
106
 
@@ -114,8 +143,14 @@ cadence state "two beers, shipping" # set self-reported state (expires in 4h)
114
143
  cadence state # print current self-report
115
144
  cadence clear # clear it
116
145
  cadence test # preview exactly what the hook would inject
146
+ cadence signals # every signal — live value, or why it's absent
117
147
  ```
118
148
 
149
+ `cadence signals` is the legibility view: it never goes silent. Every signal
150
+ Cadence knows how to read is listed with its live value, or the exact reason
151
+ it's absent — opt-in not taken, below a render threshold, missing permission
152
+ (Focus needs Full Disk Access), or platform-gated.
153
+
119
154
  From inside Claude Code, the plugin skill gives the same self-report path:
120
155
 
121
156
  ```text
@@ -184,17 +219,26 @@ the gate to run on every push to `main`.
184
219
 
185
220
  See [`BACKLOG.md`](BACKLOG.md). Highlights:
186
221
 
187
- - **More signals** — stronger `git` nudges, calendar density, wifi/place.
188
- Git is the highest-value one: it moves the dials from *what you said* to *what
189
- you're actually doing*.
222
+ - **Git nudges** — the highest-value next step: built but dormant, they move
223
+ the dials from *what you said* to *what you're actually doing*.
224
+ - **More signals** — candidates on the bench:
225
+ - **calendar density** — a meeting in 20 minutes should read as `pace=fast,
226
+ posture=decisive`; a clear afternoon as room to explore.
227
+ - **typing tempo** — prompt rhythm beyond length: rapid-fire short prompts vs.
228
+ one long considered one.
229
+ - **focused app** — what's frontmost next to the terminal (docs? a profiler?
230
+ Slack?).
231
+ - **scheduled Focus** — manual Focus detection ships now; scheduled/geofenced
232
+ Focus needs schedule math against `ModeConfigurations.json`.
190
233
  - **After-the-fact injection** — refine the cadence mid-task (`PostToolUse`),
191
234
  building on the conservative finish-line `Stop` guard that now ships.
192
235
  - **Opt-in flavor providers** — horoscope, moon phase, for those who want them.
193
236
 
194
237
  ## Caveats
195
238
 
196
- - **macOS-only music.** The now-playing reader is AppleScript. Other platforms
197
- get self-report + dials, just no music vibe.
239
+ - **macOS-only.** The alpha targets the Mac: music, battery, dark mode,
240
+ displays, wifi, and Focus are all macOS probes. Other platforms get
241
+ self-report + dials + time/git; everything else degrades silently.
198
242
  - **Spotify's Web API is not used** and not needed — Spotify deprecated audio
199
243
  features for new apps (2024) and gated dev-mode behind Premium (2026). Cadence
200
244
  reads what's playing at the OS level instead.
package/dist/cadence.js CHANGED
@@ -96,6 +96,7 @@ export function deriveCadence(state) {
96
96
  // Candidate nudges, deliberately dormant until we've watched real output:
97
97
  // conflicted → proactivity low (verify, don't barrel)
98
98
  // commitsLastHour >= 3 → pace high (flow state)
99
+ // ambient focus on → proactivity high (heads-down = fewer check-ins)
99
100
  // See BACKLOG: turn these on once the flavor proves trustworthy.
100
101
  void git;
101
102
  // ── activity → pace (returning from a break = slow back down) ─────────────
package/dist/cli.js CHANGED
@@ -8,6 +8,7 @@ import { getAmbientSignal } from "./providers/ambient.js";
8
8
  import { getGitSignal } from "./providers/git.js";
9
9
  import { deriveCadence, buildReframe, loadOverrides, applyOverrides, resolveDialLevel, DIALS, DIAL_WORDS, } from "./cadence.js";
10
10
  import { render } from "./inject.js";
11
+ import { renderSignalsTable } from "./signals-view.js";
11
12
  const CADENCE_DIR = join(homedir(), ".cadence");
12
13
  const STATE_FILE = join(CADENCE_DIR, "state.txt");
13
14
  const CONFIG_FILE = join(CADENCE_DIR, "config.json");
@@ -66,6 +67,19 @@ async function cmdTest() {
66
67
  }
67
68
  console.log("\n" + block + "\n");
68
69
  }
70
+ // The legibility view: every signal Cadence can read — live value, or the
71
+ // reason it's absent. Unlike `test`, this never goes silent.
72
+ async function cmdSignals() {
73
+ const [music, report, ambient, git] = await Promise.all([
74
+ getMusicSignal().catch(() => null),
75
+ getSelfReportSignal().catch(() => null),
76
+ getAmbientSignal(new Date()).catch(() => null),
77
+ getGitSignal(process.cwd()).catch(() => null),
78
+ ]);
79
+ console.log("\n" +
80
+ renderSignalsTable({ music, report, ambient, git, now: Date.now(), platform: process.platform }) +
81
+ "\n");
82
+ }
69
83
  const LEVELS = ["low", "medium", "high"];
70
84
  async function cmdSet(args) {
71
85
  const [dial, value] = args;
@@ -270,6 +284,7 @@ const HELP = `
270
284
  cadence state print current self-reported state
271
285
  cadence clear clear self-reported state
272
286
  cadence test preview what the hook would inject right now
287
+ cadence signals every signal — live value, or why it's absent
273
288
 
274
289
  dials (your determination — pinned dials override inference):
275
290
  cadence dials show the mixing board and what's pinned
@@ -292,6 +307,8 @@ async function main() {
292
307
  return cmdClear();
293
308
  case "test":
294
309
  return cmdTest();
310
+ case "signals":
311
+ return cmdSignals();
295
312
  case "set":
296
313
  return cmdSet(rest);
297
314
  case "unset":
package/dist/inject.js CHANGED
@@ -51,6 +51,7 @@ function renderAmbient(a) {
51
51
  a.onBattery === true
52
52
  ? `unplugged${a.batteryPct != null ? ` ${a.batteryPct}%` : ""}`
53
53
  : null,
54
+ a.focus === true ? "focus on" : null,
54
55
  a.darkMode === true ? "dark mode" : null,
55
56
  a.displays != null && a.displays > 1 ? `${a.displays} displays` : null,
56
57
  a.network ? `on ${quote(a.network)}` : null,
@@ -22,6 +22,7 @@ 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
26
  const WEATHER_TIMEOUT_MS = 900;
26
27
  const DAYS = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"];
27
28
  function partOfDay(hour) {
@@ -62,19 +63,44 @@ function getVitals() {
62
63
  const load1 = loadavg()[0] ?? 0;
63
64
  return { uptimeHours, loadHigh: load1 / cores > 0.8 };
64
65
  }
66
+ // Focus / DND — tri-state, read straight from the private donotdisturbd DB
67
+ // (~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
70
+ // undefined → unreadable: terminal lacks Full Disk Access (TCC denies the
71
+ // read silently — hook subprocesses never get a prompt), file
72
+ // 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() {
76
+ if (process.platform !== "darwin")
77
+ return undefined;
78
+ try {
79
+ const raw = await readFile(DND_ASSERTIONS, "utf-8");
80
+ const json = JSON.parse(raw);
81
+ const records = json.data?.[0]?.storeAssertionRecords;
82
+ return Array.isArray(records) && records.length > 0;
83
+ }
84
+ catch {
85
+ return undefined;
86
+ }
87
+ }
65
88
  // ── mac context: best-effort shell-outs, all flavor (no dial nudges) ─────────
66
89
  async function getMacContext() {
67
90
  if (process.platform !== "darwin")
68
91
  return {};
69
- const [dark, ssid, displays] = await Promise.all([
92
+ const [dark, ssid, displays, focus] = await Promise.all([
70
93
  sh("defaults read -g AppleInterfaceStyle"), // "Dark", or error (=light)
71
94
  sh("ipconfig getsummary en0 | awk -F ' SSID : ' '/ SSID : / {print $2}'", 700),
72
95
  // fast display count via AppleScript (~100ms) — NOT system_profiler (1-3s)
73
96
  sh(`osascript -e 'tell application "System Events" to count of desktops'`, 700),
97
+ getFocus(),
74
98
  ]);
75
99
  const ctx = {};
76
- if (dark != null)
77
- ctx.darkMode = /dark/i.test(dark);
100
+ // `defaults read` exits non-zero when the key is unset — which is exactly
101
+ // what light mode looks like. So error/null ⇒ light, not unknown.
102
+ ctx.darkMode = dark != null && /dark/i.test(dark);
103
+ ctx.focus = focus;
78
104
  if (ssid)
79
105
  ctx.network = ssid.split("\n")[0]?.trim() || undefined;
80
106
  const n = displays ? Number(displays) : NaN;
@@ -2,7 +2,7 @@ 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
- const STALE_AFTER_MS = 4 * 60 * 60 * 1000;
5
+ export const STALE_AFTER_MS = 4 * 60 * 60 * 1000;
6
6
  export async function getSelfReportSignal() {
7
7
  try {
8
8
  const [text, info] = await Promise.all([readFile(STATE_FILE, "utf-8"), stat(STATE_FILE)]);
@@ -0,0 +1,103 @@
1
+ import { STALE_AFTER_MS } from "./providers/selfreport.js";
2
+ const LABEL_W = 12; // sub-row label column
3
+ const VALUE_W = 18; // value column, before a "(hidden: …)" note
4
+ function row(label, value, note) {
5
+ const v = note ? `${value.padEnd(VALUE_W)} ${note}` : value;
6
+ return ` ${label.padEnd(LABEL_W)}${v}`;
7
+ }
8
+ function top(label, value) {
9
+ return ` ${label.padEnd(LABEL_W + 2)}${value}`;
10
+ }
11
+ function ttlLeft(setAt, now) {
12
+ const rem = Math.max(0, STALE_AFTER_MS - (now - setAt));
13
+ const h = Math.floor(rem / 3_600_000);
14
+ const m = Math.floor((rem % 3_600_000) / 60_000);
15
+ return h > 0 ? `${h}h${String(m).padStart(2, "0")}m left` : `${m}m left`;
16
+ }
17
+ function ambientRows(a, platform) {
18
+ if (!a)
19
+ return [top("ambient", "— unavailable")];
20
+ const mac = platform === "darwin";
21
+ const macNote = "— macOS only";
22
+ const lines = [" ambient"];
23
+ lines.push(row("time", `${a.partOfDay} (${a.dayOfWeek})`));
24
+ lines.push(row("weather", a.weather ?? "— off (run: cadence set-location <lat> <lon>)"));
25
+ lines.push(!mac
26
+ ? row("battery", macNote)
27
+ : a.onBattery == null
28
+ ? row("battery", "— unavailable")
29
+ : a.onBattery
30
+ ? row("battery", `unplugged${a.batteryPct != null ? `, ${a.batteryPct}%` : ""}`)
31
+ : row("battery", `plugged in${a.batteryPct != null ? `, ${a.batteryPct}%` : ""}`, "(hidden: only shows unplugged)"));
32
+ lines.push(!mac
33
+ ? row("dark mode", macNote)
34
+ : a.darkMode == null
35
+ ? row("dark mode", "— unavailable")
36
+ : a.darkMode
37
+ ? row("dark mode", "on")
38
+ : row("dark mode", "off", "(hidden: only shows on)"));
39
+ lines.push(!mac
40
+ ? row("displays", macNote)
41
+ : a.displays == null
42
+ ? row("displays", "— unavailable")
43
+ : a.displays > 1
44
+ ? row("displays", String(a.displays))
45
+ : row("displays", String(a.displays), "(hidden: only shows >1)"));
46
+ lines.push(!mac
47
+ ? row("wifi", macNote)
48
+ : a.network
49
+ ? row("wifi", JSON.stringify(a.network))
50
+ : row("wifi", "— unavailable"));
51
+ lines.push(a.uptimeHours == null
52
+ ? row("uptime", "— unavailable")
53
+ : a.uptimeHours >= 12
54
+ ? row("uptime", `${a.uptimeHours}h`)
55
+ : row("uptime", `${a.uptimeHours}h`, "(hidden: only shows ≥12h)"));
56
+ lines.push(a.loadHigh
57
+ ? row("load", "high (machine busy)")
58
+ : row("load", "normal", "(hidden: only shows high)"));
59
+ lines.push(!mac
60
+ ? row("focus", macNote)
61
+ : a.focus == null
62
+ ? row("focus", "— unavailable (terminal needs Full Disk Access)")
63
+ : a.focus
64
+ ? row("focus", "on")
65
+ : row("focus", "off", "(hidden: only shows on)"));
66
+ return lines;
67
+ }
68
+ function musicRows(m) {
69
+ if (!m?.track)
70
+ return [top("music", "— nothing playing")];
71
+ const lines = [" music"];
72
+ lines.push(row("track", `${JSON.stringify(m.track)}${m.artist ? ` — ${m.artist}` : ""}${m.player ? ` (${m.player})` : ""}`));
73
+ lines.push(m.vibe ? row("vibe", m.vibe) : row("vibe", "— no tags yet (looked up once per artist)"));
74
+ return lines;
75
+ }
76
+ function reportRow(r, now) {
77
+ if (!r)
78
+ return top("self_report", '— none set (run: cadence state "...")');
79
+ const text = r.text.length > 44 ? `${r.text.slice(0, 43)}…` : r.text;
80
+ return top("self_report", `${JSON.stringify(text)} (${ttlLeft(r.setAt, now)})`);
81
+ }
82
+ function gitRow(g) {
83
+ if (!g)
84
+ return top("git", "— not a git repo (signal is per-directory)");
85
+ const parts = [
86
+ g.commitsLastHour > 0
87
+ ? `${g.commitsLastHour} commit${g.commitsLastHour === 1 ? "" : "s"}/hr`
88
+ : null,
89
+ g.filesDirty > 0 ? `${g.filesDirty} dirty` : "clean tree",
90
+ g.minSinceLastCommit != null ? `last commit ${g.minSinceLastCommit}m ago` : null,
91
+ g.conflicted ? "mid-conflict" : null,
92
+ ].filter(Boolean);
93
+ return top("git", parts.join(", "));
94
+ }
95
+ export function renderSignalsTable(raw) {
96
+ return [
97
+ ...ambientRows(raw.ambient, raw.platform),
98
+ ...musicRows(raw.music),
99
+ reportRow(raw.report, raw.now),
100
+ gitRow(raw.git),
101
+ top("activity", "— session-only (the hook injects it per-prompt)"),
102
+ ].join("\n");
103
+ }
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@cullumco/cadence",
3
- "version": "0.1.1",
4
- "description": "Agents that read the room. Ambient context, cadence dials, and finish-line guardrails for Claude Code.",
3
+ "version": "0.1.2",
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": {
7
7
  "cadence": "bin/cadence"
8
8
  },
9
- "homepage": "https://github.com/cullumco/cadence",
9
+ "homepage": "https://cullumco.github.io/cadence/",
10
10
  "repository": {
11
11
  "type": "git",
12
12
  "url": "git+https://github.com/cullumco/cadence.git"