@cullumco/cadence 0.1.0 → 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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +3 -3
- package/README.md +54 -10
- package/dist/cadence.js +1 -0
- package/dist/cli.js +138 -6
- package/dist/debug.js +17 -0
- package/dist/hook.js +5 -1
- package/dist/inject.js +1 -0
- package/dist/providers/ambient.js +29 -3
- package/dist/providers/music.js +59 -27
- package/dist/providers/selfreport.js +1 -1
- package/dist/session-start.js +64 -0
- package/dist/signals-view.js +103 -0
- package/hooks/hooks.json +11 -0
- package/package.json +3 -3
|
@@ -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.
|
|
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.
|
|
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.
|
|
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
|
|
44
|
-
|
|
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
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
- **
|
|
188
|
-
|
|
189
|
-
|
|
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
|
|
197
|
-
|
|
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");
|
|
@@ -32,7 +33,9 @@ async function cmdClear() {
|
|
|
32
33
|
await writeFile(STATE_FILE, "", "utf-8");
|
|
33
34
|
console.log(" state cleared");
|
|
34
35
|
}
|
|
35
|
-
|
|
36
|
+
// Collects live signals and renders the exact block the hook would inject,
|
|
37
|
+
// or null when there's nothing to say. Shared by `test` and the bare command.
|
|
38
|
+
async function buildPreview() {
|
|
36
39
|
const signals = [];
|
|
37
40
|
const [music, report, ambient, git, overrides] = await Promise.all([
|
|
38
41
|
getMusicSignal().catch(() => null),
|
|
@@ -49,14 +52,33 @@ async function cmdTest() {
|
|
|
49
52
|
signals.push(ambient);
|
|
50
53
|
if (git)
|
|
51
54
|
signals.push(git);
|
|
52
|
-
if (signals.length === 0 && Object.keys(overrides).length === 0)
|
|
53
|
-
|
|
54
|
-
return;
|
|
55
|
-
}
|
|
55
|
+
if (signals.length === 0 && Object.keys(overrides).length === 0)
|
|
56
|
+
return null;
|
|
56
57
|
const state = { signals, capturedAt: Date.now() };
|
|
57
58
|
const { cadence, pinned } = applyOverrides(deriveCadence(state), overrides);
|
|
58
59
|
const reframe = buildReframe(cadence);
|
|
59
|
-
|
|
60
|
+
return render({ ...state, cadence, pinned, reframe });
|
|
61
|
+
}
|
|
62
|
+
async function cmdTest() {
|
|
63
|
+
const block = await buildPreview();
|
|
64
|
+
if (!block) {
|
|
65
|
+
console.log(' (no signals — play something, set: cadence state "...", or pin a dial: cadence set pace fast)');
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
console.log("\n" + block + "\n");
|
|
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");
|
|
60
82
|
}
|
|
61
83
|
const LEVELS = ["low", "medium", "high"];
|
|
62
84
|
async function cmdSet(args) {
|
|
@@ -150,14 +172,119 @@ async function cmdLocation(args) {
|
|
|
150
172
|
await writeFile(CONFIG_FILE, JSON.stringify(cfg, null, 2), "utf-8");
|
|
151
173
|
console.log(` location set${nameParts.length ? ` (${nameParts.join(" ")})` : ""} — weather is now on`);
|
|
152
174
|
}
|
|
175
|
+
// Has the user ever told Cadence anything? (Signals like time-of-day always
|
|
176
|
+
// exist, so "fresh install" is detected by absence of user INPUT, not signals.)
|
|
177
|
+
async function hasUserInput() {
|
|
178
|
+
const [state, config] = await Promise.all([
|
|
179
|
+
readFile(STATE_FILE, "utf-8").catch(() => ""),
|
|
180
|
+
readFile(CONFIG_FILE, "utf-8").catch(() => "{}"),
|
|
181
|
+
]);
|
|
182
|
+
let cfg = {};
|
|
183
|
+
try {
|
|
184
|
+
cfg = JSON.parse(config);
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
// unreadable config = no input
|
|
188
|
+
}
|
|
189
|
+
return state.trim().length > 0 || Object.keys(cfg).length > 0;
|
|
190
|
+
}
|
|
191
|
+
const INPUTS_FOOTER = ` where you can input:
|
|
192
|
+
cadence state "..." how you are right now (4h TTL)
|
|
193
|
+
cadence set <dial> <level> pin a dial: ${DIALS.join(", ")}
|
|
194
|
+
cadence set-location <lat> <lon> opt into weather
|
|
195
|
+
cadence start interactive setup
|
|
196
|
+
cadence help everything else`;
|
|
197
|
+
// Bare \`cadence\`: live status + where to input — not a help dump.
|
|
198
|
+
async function cmdRoot() {
|
|
199
|
+
if (!(await hasUserInput())) {
|
|
200
|
+
console.log("\n cadence — agents that read the room");
|
|
201
|
+
console.log(" It hasn't heard from you yet. Fastest start:\n");
|
|
202
|
+
console.log(' cadence start guided setup (~30s)');
|
|
203
|
+
console.log(' cadence state "ship mode" or just say how you are\n');
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
const block = await buildPreview();
|
|
207
|
+
if (block) {
|
|
208
|
+
console.log("\n" + block + "\n");
|
|
209
|
+
}
|
|
210
|
+
else {
|
|
211
|
+
console.log("\n (no signals right now)\n");
|
|
212
|
+
}
|
|
213
|
+
console.log(INPUTS_FOOTER + "\n");
|
|
214
|
+
}
|
|
215
|
+
// Guided first run: three prompts, every one skippable, nothing destructive.
|
|
216
|
+
async function cmdStart() {
|
|
217
|
+
if (!process.stdin.isTTY) {
|
|
218
|
+
console.log(' cadence start is interactive — run it in a terminal, or use: cadence state "..."');
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
const { createInterface } = await import("node:readline/promises");
|
|
222
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
223
|
+
try {
|
|
224
|
+
console.log("\n cadence — agents that read the room");
|
|
225
|
+
console.log(" Three questions. Enter skips any of them; everything can be changed later.\n");
|
|
226
|
+
// 1 ── self-reported state: the highest-leverage input
|
|
227
|
+
const state = (await rl.question(' 1/3 How are you right now? (e.g. "two beers, ship mode")\n > ')).trim();
|
|
228
|
+
if (state) {
|
|
229
|
+
await mkdir(CADENCE_DIR, { recursive: true });
|
|
230
|
+
await writeFile(STATE_FILE, state, "utf-8");
|
|
231
|
+
console.log(' ✓ set — expires after 4h; update anytime: cadence state "..."\n');
|
|
232
|
+
}
|
|
233
|
+
else {
|
|
234
|
+
console.log(' skipped — later: cadence state "..."\n');
|
|
235
|
+
}
|
|
236
|
+
// 2 ── dial pins: overrides, so only offered, never pushed
|
|
237
|
+
console.log(` 2/3 Pin any dials? Pins override inference until unset.`);
|
|
238
|
+
console.log(` dials: ${DIALS.join(", ")} — levels: low|medium|high`);
|
|
239
|
+
for (;;) {
|
|
240
|
+
const ans = (await rl.question(' pin (e.g. "pace high", enter to continue) > ')).trim();
|
|
241
|
+
if (!ans)
|
|
242
|
+
break;
|
|
243
|
+
const [dial, value] = ans.split(/\s+/);
|
|
244
|
+
if (!dial || !value || !DIALS.includes(dial)) {
|
|
245
|
+
console.log(` format: <dial> <level>, dials: ${DIALS.join(", ")}`);
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
const d = dial;
|
|
249
|
+
const level = resolveDialLevel(d, value);
|
|
250
|
+
if (!level) {
|
|
251
|
+
console.log(` "${value}" isn't valid for ${dial} — use low|medium|high`);
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
await cmdSet([dial, value]);
|
|
255
|
+
}
|
|
256
|
+
console.log();
|
|
257
|
+
// 3 ── weather: explicitly opt-in, mirrors cmdLocation's no-silent-geo rule
|
|
258
|
+
const loc = (await rl.question(" 3/3 Weather? Give a location, or enter to leave it off.\n lat lon [name] (e.g. 40.71 -74.01 NYC) > ")).trim();
|
|
259
|
+
if (loc) {
|
|
260
|
+
await cmdLocation(loc.split(/\s+/));
|
|
261
|
+
}
|
|
262
|
+
else {
|
|
263
|
+
console.log(" skipped — weather stays off until: cadence set-location <lat> <lon>");
|
|
264
|
+
}
|
|
265
|
+
console.log("\n Done. Here's exactly what the hook injects right now:");
|
|
266
|
+
await cmdTest();
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
// Ctrl+D / Ctrl+C mid-wizard: every step saves as it goes, so an early
|
|
270
|
+
// exit just means "stop asking" — never an error, never a rollback.
|
|
271
|
+
console.log("\n setup ended early — anything you answered is saved\n");
|
|
272
|
+
}
|
|
273
|
+
finally {
|
|
274
|
+
rl.close();
|
|
275
|
+
}
|
|
276
|
+
}
|
|
153
277
|
const HELP = `
|
|
154
278
|
cadence — agents that read the room
|
|
155
279
|
|
|
156
280
|
daily:
|
|
281
|
+
cadence live status + where to input
|
|
282
|
+
cadence start guided setup (state, dials, weather — all skippable)
|
|
157
283
|
cadence state "..." set self-reported state (e.g. "two beers, ship mode")
|
|
158
284
|
cadence state print current self-reported state
|
|
159
285
|
cadence clear clear self-reported state
|
|
160
286
|
cadence test preview what the hook would inject right now
|
|
287
|
+
cadence signals every signal — live value, or why it's absent
|
|
161
288
|
|
|
162
289
|
dials (your determination — pinned dials override inference):
|
|
163
290
|
cadence dials show the mixing board and what's pinned
|
|
@@ -172,12 +299,16 @@ const HELP = `
|
|
|
172
299
|
async function main() {
|
|
173
300
|
const [cmd, ...rest] = process.argv.slice(2);
|
|
174
301
|
switch (cmd) {
|
|
302
|
+
case "start":
|
|
303
|
+
return cmdStart();
|
|
175
304
|
case "state":
|
|
176
305
|
return cmdState(rest);
|
|
177
306
|
case "clear":
|
|
178
307
|
return cmdClear();
|
|
179
308
|
case "test":
|
|
180
309
|
return cmdTest();
|
|
310
|
+
case "signals":
|
|
311
|
+
return cmdSignals();
|
|
181
312
|
case "set":
|
|
182
313
|
return cmdSet(rest);
|
|
183
314
|
case "unset":
|
|
@@ -187,6 +318,7 @@ async function main() {
|
|
|
187
318
|
case "set-location":
|
|
188
319
|
return cmdLocation(rest);
|
|
189
320
|
case undefined:
|
|
321
|
+
return cmdRoot(); // live status + inputs, not the help dump
|
|
190
322
|
case "help":
|
|
191
323
|
case "--help":
|
|
192
324
|
case "-h":
|
package/dist/debug.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/* ─────────────────────────────────────────────────────────────────────────
|
|
2
|
+
* CADENCE_DEBUG=1 surfaces swallowed provider errors on stderr.
|
|
3
|
+
*
|
|
4
|
+
* Providers are fail-silent by contract: a broken signal must degrade to
|
|
5
|
+
* "no signal," never break the hook. But fail-silent code can mask a
|
|
6
|
+
* 100%-reproducible bug (see: the AppleScript that never compiled). This
|
|
7
|
+
* is the escape hatch — stderr only, never stdout, because stdout is the
|
|
8
|
+
* hook protocol channel Claude Code parses.
|
|
9
|
+
*
|
|
10
|
+
* CADENCE_DEBUG=1 node bin/cadence test
|
|
11
|
+
* ───────────────────────────────────────────────────────────────────────── */
|
|
12
|
+
const DEBUG = process.env["CADENCE_DEBUG"] === "1" || process.env["CADENCE_DEBUG"] === "true";
|
|
13
|
+
export function debug(scope, msg) {
|
|
14
|
+
if (!DEBUG)
|
|
15
|
+
return;
|
|
16
|
+
process.stderr.write(`[cadence:${scope}] ${msg}\n`);
|
|
17
|
+
}
|
package/dist/hook.js
CHANGED
|
@@ -6,6 +6,7 @@ import { getGitSignal } from "./providers/git.js";
|
|
|
6
6
|
import { getActivitySignal } from "./providers/activity.js";
|
|
7
7
|
import { deriveCadence, buildReframe, loadOverrides, applyOverrides } from "./cadence.js";
|
|
8
8
|
import { render } from "./inject.js";
|
|
9
|
+
import { debug } from "./debug.js";
|
|
9
10
|
const TOTAL_BUDGET_MS = 1500;
|
|
10
11
|
// Claude Code alpha adapter: collect portable Cadence signals, derive the core
|
|
11
12
|
// cadence state, then deliver it through UserPromptSubmit.additionalContext.
|
|
@@ -53,7 +54,10 @@ async function main() {
|
|
|
53
54
|
const [signals, overrides] = await Promise.all([
|
|
54
55
|
Promise.race([
|
|
55
56
|
collectSignals(projectDir, prompt),
|
|
56
|
-
new Promise((resolve) => setTimeout(() =>
|
|
57
|
+
new Promise((resolve) => setTimeout(() => {
|
|
58
|
+
debug("hook", `signal collection exceeded ${TOTAL_BUDGET_MS}ms budget — injecting without signals`);
|
|
59
|
+
resolve([]);
|
|
60
|
+
}, TOTAL_BUDGET_MS)),
|
|
57
61
|
]),
|
|
58
62
|
loadOverrides(),
|
|
59
63
|
]);
|
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
|
-
|
|
77
|
-
|
|
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;
|
package/dist/providers/music.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
2
|
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 { debug } from "../debug.js";
|
|
6
7
|
/* ─────────────────────────────────────────────────────────────────────────
|
|
7
8
|
* Music = identity + vibe. No Spotify Web API, no auth, no Premium.
|
|
8
9
|
*
|
|
@@ -17,36 +18,66 @@ const CACHE_FILE = join(homedir(), ".cadence", "vibe-cache.json");
|
|
|
17
18
|
const MB_TIMEOUT_MS = 1000;
|
|
18
19
|
const MAX_TAGS = 4;
|
|
19
20
|
const UA = "cadence/0.1 (https://github.com/cullumco/cadence)";
|
|
20
|
-
//
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
if
|
|
34
|
-
|
|
21
|
+
// Spotify first (matches historical priority), then Apple Music.
|
|
22
|
+
const PLAYERS = ["Spotify", "Music"];
|
|
23
|
+
/* The app name MUST be a literal inside the script: AppleScript resolves
|
|
24
|
+
* terms like `player state` against the target app's scripting dictionary
|
|
25
|
+
* at COMPILE time, so `tell application someVariable` is a guaranteed
|
|
26
|
+
* syntax error (-2741). One script per player, built from a template.
|
|
27
|
+
* Exported for the compile-check regression test. */
|
|
28
|
+
export function playerScript(app) {
|
|
29
|
+
return `
|
|
30
|
+
if application "${app}" is running then
|
|
31
|
+
tell application "${app}"
|
|
32
|
+
if player state is playing then
|
|
33
|
+
return (name of current track) & "|||" & (artist of current track)
|
|
34
|
+
end if
|
|
35
|
+
end tell
|
|
36
|
+
end if
|
|
37
|
+
return ""
|
|
35
38
|
`;
|
|
36
|
-
|
|
39
|
+
}
|
|
40
|
+
/* Compiling `tell application "Spotify"` makes macOS locate the app — on a
|
|
41
|
+
* machine where it isn't installed that can pop a "Where is Spotify?"
|
|
42
|
+
* picker, from a background hook. pgrep the process list first so we only
|
|
43
|
+
* ever compile scripts for players that are actually running. */
|
|
44
|
+
function isRunning(app) {
|
|
37
45
|
return new Promise((resolve) => {
|
|
38
|
-
const child =
|
|
39
|
-
child.on("error", () => resolve(
|
|
46
|
+
const child = execFile("pgrep", ["-qx", app], { timeout: 500 }, (err) => resolve(!err));
|
|
47
|
+
child.on("error", () => resolve(false));
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
/* execFile, not exec: the script must reach osascript byte-for-byte as one
|
|
51
|
+
* argv entry. Routing it through a shell means a quoting layer (where `\n`
|
|
52
|
+
* inside double quotes stays a literal backslash-n — instant -2740). */
|
|
53
|
+
export function osascript(script) {
|
|
54
|
+
return new Promise((resolve) => {
|
|
55
|
+
const child = execFile("osascript", ["-e", script], { timeout: 800 }, (err, stdout, stderr) => {
|
|
56
|
+
if (err)
|
|
57
|
+
debug("music", `osascript failed: ${stderr.trim() || err.message}`);
|
|
58
|
+
resolve(err ? "" : stdout.trim());
|
|
59
|
+
});
|
|
60
|
+
child.on("error", (e) => {
|
|
61
|
+
debug("music", `osascript spawn failed: ${e.message}`);
|
|
62
|
+
resolve("");
|
|
63
|
+
});
|
|
40
64
|
});
|
|
41
65
|
}
|
|
42
66
|
async function getNowPlaying() {
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
67
|
+
for (const player of PLAYERS) {
|
|
68
|
+
if (!(await isRunning(player))) {
|
|
69
|
+
debug("music", `${player} not running`);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const out = await osascript(playerScript(player));
|
|
73
|
+
if (!out)
|
|
74
|
+
continue; // running but paused/stopped (or script error, logged above)
|
|
75
|
+
const [track, artist] = out.split("|||");
|
|
76
|
+
if (!track || !artist)
|
|
77
|
+
continue;
|
|
78
|
+
return { track, artist, player };
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
50
81
|
}
|
|
51
82
|
async function loadCache() {
|
|
52
83
|
try {
|
|
@@ -92,7 +123,8 @@ async function fetchTags(artist) {
|
|
|
92
123
|
.slice(0, MAX_TAGS);
|
|
93
124
|
return cleaned.length ? cleaned : null;
|
|
94
125
|
}
|
|
95
|
-
catch {
|
|
126
|
+
catch (e) {
|
|
127
|
+
debug("music", `musicbrainz lookup failed for "${artist}": ${e instanceof Error ? e.message : String(e)}`);
|
|
96
128
|
return null;
|
|
97
129
|
}
|
|
98
130
|
finally {
|
|
@@ -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,64 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { getMusicSignal } from "./providers/music.js";
|
|
3
|
+
import { getSelfReportSignal } from "./providers/selfreport.js";
|
|
4
|
+
import { loadOverrides } from "./cadence.js";
|
|
5
|
+
import { debug } from "./debug.js";
|
|
6
|
+
/* ─────────────────────────────────────────────────────────────────────────
|
|
7
|
+
* Claude Code SessionStart adapter: one short, human-facing line when a
|
|
8
|
+
* session opens — is Cadence live, what does it currently see, and where
|
|
9
|
+
* do you input state. Discoverability, not context: the per-prompt
|
|
10
|
+
* UserPromptSubmit hook owns what the MODEL sees; this line is for YOU
|
|
11
|
+
* (delivered via `systemMessage`, which Claude Code shows to the user).
|
|
12
|
+
*
|
|
13
|
+
* Fires on "startup" only (see hooks/hooks.json matcher) — not on resume
|
|
14
|
+
* or clear — so it reads as a greeting, not a nag.
|
|
15
|
+
* ───────────────────────────────────────────────────────────────────────── */
|
|
16
|
+
const BUDGET_MS = 700;
|
|
17
|
+
// The voice of the product's first impression. Return null to stay silent.
|
|
18
|
+
// Default policy: always one line on startup — say what's seen when there
|
|
19
|
+
// are signals, point at the inputs when there's nothing yet.
|
|
20
|
+
export function composeHint(info) {
|
|
21
|
+
if (info.firstRun) {
|
|
22
|
+
return 'cadence: on, but it hasn\'t heard from you — try `cadence start` (or just `cadence state "deep work"`)';
|
|
23
|
+
}
|
|
24
|
+
const seen = [];
|
|
25
|
+
if (info.selfReport)
|
|
26
|
+
seen.push(`state "${info.selfReport}"`);
|
|
27
|
+
if (info.nowPlaying)
|
|
28
|
+
seen.push(`${info.nowPlaying.player}: ${info.nowPlaying.artist}`);
|
|
29
|
+
if (info.pinned.length)
|
|
30
|
+
seen.push(`pinned ${info.pinned.join(", ")}`);
|
|
31
|
+
if (seen.length === 0) {
|
|
32
|
+
return 'cadence: live, no signals right now — `cadence state "..."` to give it one';
|
|
33
|
+
}
|
|
34
|
+
return `cadence: live — ${seen.join(" · ")} (inputs: cadence state | dials)`;
|
|
35
|
+
}
|
|
36
|
+
async function collectInfo() {
|
|
37
|
+
// Race music against the budget — MusicBrainz on a brand-new artist can
|
|
38
|
+
// be slow, and a session greeting must never delay the session.
|
|
39
|
+
const [report, overrides, music] = await Promise.all([
|
|
40
|
+
getSelfReportSignal().catch(() => null),
|
|
41
|
+
loadOverrides(),
|
|
42
|
+
Promise.race([
|
|
43
|
+
getMusicSignal().catch(() => null),
|
|
44
|
+
new Promise((resolve) => setTimeout(() => resolve(null), BUDGET_MS)),
|
|
45
|
+
]),
|
|
46
|
+
]);
|
|
47
|
+
const pinned = Object.keys(overrides);
|
|
48
|
+
return {
|
|
49
|
+
selfReport: report?.text ?? null,
|
|
50
|
+
pinned,
|
|
51
|
+
nowPlaying: music?.artist ? { artist: music.artist, player: music.player ?? "music" } : null,
|
|
52
|
+
firstRun: !report && pinned.length === 0,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
async function main() {
|
|
56
|
+
const hint = composeHint(await collectInfo());
|
|
57
|
+
if (!hint)
|
|
58
|
+
process.exit(0); // same contract as the prompt hook: silent when empty
|
|
59
|
+
process.stdout.write(JSON.stringify({ systemMessage: hint }));
|
|
60
|
+
}
|
|
61
|
+
main().catch((err) => {
|
|
62
|
+
debug("session-start", err instanceof Error ? err.message : String(err));
|
|
63
|
+
process.exit(0); // greeting must never break a session
|
|
64
|
+
});
|
|
@@ -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/hooks/hooks.json
CHANGED
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cullumco/cadence",
|
|
3
|
-
"version": "0.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.
|
|
9
|
+
"homepage": "https://cullumco.github.io/cadence/",
|
|
10
10
|
"repository": {
|
|
11
11
|
"type": "git",
|
|
12
12
|
"url": "git+https://github.com/cullumco/cadence.git"
|