@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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +2 -2
- package/README.md +114 -66
- package/dist/cadence.js +58 -17
- package/dist/cli.js +238 -29
- package/dist/config.js +77 -0
- package/dist/hook.js +40 -18
- package/dist/inject.js +21 -3
- package/dist/posttool.js +126 -9
- package/dist/providers/activity.js +45 -4
- package/dist/providers/{ambient.js → environment.js} +67 -8
- package/dist/providers/esoteric.js +77 -0
- package/dist/providers/git.js +4 -3
- package/dist/providers/intent.js +37 -0
- package/dist/providers/moon.js +51 -0
- package/dist/providers/music.js +15 -3
- package/dist/providers/selfreport.js +6 -1
- package/dist/providers/spotify.js +142 -0
- package/dist/session-start.js +38 -8
- package/dist/signals-view.js +45 -11
- package/dist/spotify-auth.js +136 -0
- package/dist/stop.js +9 -6
- package/dist/types.js +1 -0
- package/dist/vibe.js +10 -6
- package/package.json +2 -2
- package/skills/pause/SKILL.md +16 -0
- package/skills/resume/SKILL.md +12 -0
- package/skills/setup/SKILL.md +43 -0
- package/skills/state/SKILL.md +3 -3
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
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
//
|
|
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
|
-
|
|
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]
|
|
101
|
-
const
|
|
102
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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: "
|
|
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
|
+
}
|
package/dist/providers/git.js
CHANGED
|
@@ -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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
+
}
|
package/dist/providers/music.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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)]);
|