@cullumco/cadence 0.1.0
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 +29 -0
- package/.claude-plugin/plugin.json +20 -0
- package/ALPHA.md +84 -0
- package/README.md +204 -0
- package/bin/cadence +5 -0
- package/bin/cadence.js +5 -0
- package/dist/cadence.js +187 -0
- package/dist/cli.js +205 -0
- package/dist/hook.js +80 -0
- package/dist/inject.js +106 -0
- package/dist/providers/activity.js +35 -0
- package/dist/providers/ambient.js +158 -0
- package/dist/providers/git.js +52 -0
- package/dist/providers/music.js +151 -0
- package/dist/providers/selfreport.js +19 -0
- package/dist/stop.js +99 -0
- package/dist/types.js +16 -0
- package/dist/vibe.js +96 -0
- package/hooks/hooks.json +26 -0
- package/package.json +60 -0
- package/skills/state/SKILL.md +19 -0
- package/skills/try/SKILL.md +19 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { writeFile, mkdir, readFile } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { getMusicSignal } from "./providers/music.js";
|
|
6
|
+
import { getSelfReportSignal } from "./providers/selfreport.js";
|
|
7
|
+
import { getAmbientSignal } from "./providers/ambient.js";
|
|
8
|
+
import { getGitSignal } from "./providers/git.js";
|
|
9
|
+
import { deriveCadence, buildReframe, loadOverrides, applyOverrides, resolveDialLevel, DIALS, DIAL_WORDS, } from "./cadence.js";
|
|
10
|
+
import { render } from "./inject.js";
|
|
11
|
+
const CADENCE_DIR = join(homedir(), ".cadence");
|
|
12
|
+
const STATE_FILE = join(CADENCE_DIR, "state.txt");
|
|
13
|
+
const CONFIG_FILE = join(CADENCE_DIR, "config.json");
|
|
14
|
+
async function cmdState(args) {
|
|
15
|
+
if (args.length === 0) {
|
|
16
|
+
try {
|
|
17
|
+
const text = (await readFile(STATE_FILE, "utf-8")).trim();
|
|
18
|
+
console.log(text || "(no state set)");
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
console.log("(no state set)");
|
|
22
|
+
}
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
const text = args.join(" ");
|
|
26
|
+
await mkdir(CADENCE_DIR, { recursive: true });
|
|
27
|
+
await writeFile(STATE_FILE, text, "utf-8");
|
|
28
|
+
console.log(` state set: "${text}"`);
|
|
29
|
+
}
|
|
30
|
+
async function cmdClear() {
|
|
31
|
+
await mkdir(CADENCE_DIR, { recursive: true });
|
|
32
|
+
await writeFile(STATE_FILE, "", "utf-8");
|
|
33
|
+
console.log(" state cleared");
|
|
34
|
+
}
|
|
35
|
+
async function cmdTest() {
|
|
36
|
+
const signals = [];
|
|
37
|
+
const [music, report, ambient, git, overrides] = await Promise.all([
|
|
38
|
+
getMusicSignal().catch(() => null),
|
|
39
|
+
getSelfReportSignal().catch(() => null),
|
|
40
|
+
getAmbientSignal(new Date()).catch(() => null),
|
|
41
|
+
getGitSignal(process.cwd()).catch(() => null),
|
|
42
|
+
loadOverrides(),
|
|
43
|
+
]);
|
|
44
|
+
if (music)
|
|
45
|
+
signals.push(music);
|
|
46
|
+
if (report)
|
|
47
|
+
signals.push(report);
|
|
48
|
+
if (ambient)
|
|
49
|
+
signals.push(ambient);
|
|
50
|
+
if (git)
|
|
51
|
+
signals.push(git);
|
|
52
|
+
if (signals.length === 0 && Object.keys(overrides).length === 0) {
|
|
53
|
+
console.log(' (no signals — play something, set: cadence state "...", or pin a dial: cadence set pace fast)');
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const state = { signals, capturedAt: Date.now() };
|
|
57
|
+
const { cadence, pinned } = applyOverrides(deriveCadence(state), overrides);
|
|
58
|
+
const reframe = buildReframe(cadence);
|
|
59
|
+
console.log("\n" + render({ ...state, cadence, pinned, reframe }) + "\n");
|
|
60
|
+
}
|
|
61
|
+
const LEVELS = ["low", "medium", "high"];
|
|
62
|
+
async function cmdSet(args) {
|
|
63
|
+
const [dial, value] = args;
|
|
64
|
+
if (!dial || !value) {
|
|
65
|
+
console.log(" usage: cadence set <dial> <value> e.g. cadence set pace fast");
|
|
66
|
+
console.log(` dials: ${DIALS.join(", ")}`);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (!DIALS.includes(dial)) {
|
|
70
|
+
console.error(` unknown dial "${dial}" — choose from: ${DIALS.join(", ")}`);
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
73
|
+
const d = dial;
|
|
74
|
+
const level = resolveDialLevel(d, value);
|
|
75
|
+
if (!level) {
|
|
76
|
+
const words = LEVELS.map((l) => DIAL_WORDS[d][l]).join(" | ");
|
|
77
|
+
console.error(` "${value}" isn't valid for ${dial}. Use: ${words} (or low|medium|high)`);
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
await mkdir(CADENCE_DIR, { recursive: true });
|
|
81
|
+
let cfg = {};
|
|
82
|
+
try {
|
|
83
|
+
cfg = JSON.parse(await readFile(CONFIG_FILE, "utf-8"));
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// no config yet
|
|
87
|
+
}
|
|
88
|
+
cfg[dial] = level;
|
|
89
|
+
await writeFile(CONFIG_FILE, JSON.stringify(cfg, null, 2), "utf-8");
|
|
90
|
+
console.log(` pinned ${dial} = ${DIAL_WORDS[d][level]} (${level})`);
|
|
91
|
+
}
|
|
92
|
+
async function cmdUnset(args) {
|
|
93
|
+
const [dial] = args;
|
|
94
|
+
await mkdir(CADENCE_DIR, { recursive: true });
|
|
95
|
+
let cfg = {};
|
|
96
|
+
try {
|
|
97
|
+
cfg = JSON.parse(await readFile(CONFIG_FILE, "utf-8"));
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
// none
|
|
101
|
+
}
|
|
102
|
+
if (!dial || dial === "all") {
|
|
103
|
+
await writeFile(CONFIG_FILE, "{}", "utf-8");
|
|
104
|
+
console.log(" unpinned all dials — back to fully inferred");
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
delete cfg[dial];
|
|
108
|
+
await writeFile(CONFIG_FILE, JSON.stringify(cfg, null, 2), "utf-8");
|
|
109
|
+
console.log(` unpinned ${dial} — back to inferred`);
|
|
110
|
+
}
|
|
111
|
+
async function cmdDials() {
|
|
112
|
+
const overrides = await loadOverrides();
|
|
113
|
+
console.log("\n cadence dials (* = pinned by you):\n");
|
|
114
|
+
for (const dial of DIALS) {
|
|
115
|
+
const pinnedLevel = overrides[dial];
|
|
116
|
+
const opts = LEVELS.map((l) => {
|
|
117
|
+
const word = DIAL_WORDS[dial][l];
|
|
118
|
+
return l === pinnedLevel ? `[${word}]*` : word;
|
|
119
|
+
}).join(" ");
|
|
120
|
+
console.log(` ${dial.padEnd(12)} ${opts}`);
|
|
121
|
+
}
|
|
122
|
+
console.log("\n pin one: cadence set <dial> <low|medium|high>");
|
|
123
|
+
console.log(" unpin: cadence unset <dial> (or: cadence unset all)\n");
|
|
124
|
+
}
|
|
125
|
+
// Weather is opt-in: it only activates once a location is configured. No
|
|
126
|
+
// silent geolocation — the user provides coordinates explicitly.
|
|
127
|
+
async function cmdLocation(args) {
|
|
128
|
+
const [lat, lon, ...nameParts] = args;
|
|
129
|
+
if (!lat || !lon) {
|
|
130
|
+
console.log(" usage: cadence set-location <lat> <lon> [name]");
|
|
131
|
+
console.log(" e.g. cadence set-location 40.71 -74.01 NYC");
|
|
132
|
+
console.log(" (find yours at https://www.latlong.net — weather stays off until set)");
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
const latNum = Number(lat);
|
|
136
|
+
const lonNum = Number(lon);
|
|
137
|
+
if (!Number.isFinite(latNum) || !Number.isFinite(lonNum)) {
|
|
138
|
+
console.error(" lat and lon must be numbers");
|
|
139
|
+
process.exit(1);
|
|
140
|
+
}
|
|
141
|
+
await mkdir(CADENCE_DIR, { recursive: true });
|
|
142
|
+
let cfg = {};
|
|
143
|
+
try {
|
|
144
|
+
cfg = JSON.parse(await readFile(CONFIG_FILE, "utf-8"));
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
// none
|
|
148
|
+
}
|
|
149
|
+
cfg["location"] = { lat: latNum, lon: lonNum, name: nameParts.join(" ") || undefined };
|
|
150
|
+
await writeFile(CONFIG_FILE, JSON.stringify(cfg, null, 2), "utf-8");
|
|
151
|
+
console.log(` location set${nameParts.length ? ` (${nameParts.join(" ")})` : ""} — weather is now on`);
|
|
152
|
+
}
|
|
153
|
+
const HELP = `
|
|
154
|
+
cadence — agents that read the room
|
|
155
|
+
|
|
156
|
+
daily:
|
|
157
|
+
cadence state "..." set self-reported state (e.g. "two beers, ship mode")
|
|
158
|
+
cadence state print current self-reported state
|
|
159
|
+
cadence clear clear self-reported state
|
|
160
|
+
cadence test preview what the hook would inject right now
|
|
161
|
+
|
|
162
|
+
dials (your determination — pinned dials override inference):
|
|
163
|
+
cadence dials show the mixing board and what's pinned
|
|
164
|
+
cadence set <dial> <level> pin a dial (level: low|medium|high)
|
|
165
|
+
cadence unset <dial> un-pin a dial (or: cadence unset all)
|
|
166
|
+
dials: pace, tone, posture, proactivity
|
|
167
|
+
(env also works: CADENCE_PACE=fast)
|
|
168
|
+
|
|
169
|
+
ambient (time & day are automatic; weather is opt-in):
|
|
170
|
+
cadence set-location <lat> <lon> [name] turn on weather for your area
|
|
171
|
+
`;
|
|
172
|
+
async function main() {
|
|
173
|
+
const [cmd, ...rest] = process.argv.slice(2);
|
|
174
|
+
switch (cmd) {
|
|
175
|
+
case "state":
|
|
176
|
+
return cmdState(rest);
|
|
177
|
+
case "clear":
|
|
178
|
+
return cmdClear();
|
|
179
|
+
case "test":
|
|
180
|
+
return cmdTest();
|
|
181
|
+
case "set":
|
|
182
|
+
return cmdSet(rest);
|
|
183
|
+
case "unset":
|
|
184
|
+
return cmdUnset(rest);
|
|
185
|
+
case "dials":
|
|
186
|
+
return cmdDials();
|
|
187
|
+
case "set-location":
|
|
188
|
+
return cmdLocation(rest);
|
|
189
|
+
case undefined:
|
|
190
|
+
case "help":
|
|
191
|
+
case "--help":
|
|
192
|
+
case "-h":
|
|
193
|
+
console.log(HELP);
|
|
194
|
+
return;
|
|
195
|
+
default:
|
|
196
|
+
console.error(`unknown command: ${cmd}`);
|
|
197
|
+
console.log(HELP);
|
|
198
|
+
process.exit(1);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
main().catch((err) => {
|
|
202
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
203
|
+
console.error(`error: ${msg}`);
|
|
204
|
+
process.exit(1);
|
|
205
|
+
});
|
package/dist/hook.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { getMusicSignal } from "./providers/music.js";
|
|
3
|
+
import { getSelfReportSignal } from "./providers/selfreport.js";
|
|
4
|
+
import { getAmbientSignal } from "./providers/ambient.js";
|
|
5
|
+
import { getGitSignal } from "./providers/git.js";
|
|
6
|
+
import { getActivitySignal } from "./providers/activity.js";
|
|
7
|
+
import { deriveCadence, buildReframe, loadOverrides, applyOverrides } from "./cadence.js";
|
|
8
|
+
import { render } from "./inject.js";
|
|
9
|
+
const TOTAL_BUDGET_MS = 1500;
|
|
10
|
+
// Claude Code alpha adapter: collect portable Cadence signals, derive the core
|
|
11
|
+
// cadence state, then deliver it through UserPromptSubmit.additionalContext.
|
|
12
|
+
// Keep Claude-specific payload/output details here so the core stays reusable.
|
|
13
|
+
// Claude Code writes a JSON payload to stdin; it includes `cwd` (the project
|
|
14
|
+
// dir). We read it so the git provider inspects the RIGHT repo, not wherever
|
|
15
|
+
// the hook binary happens to live.
|
|
16
|
+
async function readStdin() {
|
|
17
|
+
if (process.stdin.isTTY)
|
|
18
|
+
return {};
|
|
19
|
+
let raw = "";
|
|
20
|
+
for await (const chunk of process.stdin)
|
|
21
|
+
raw += chunk;
|
|
22
|
+
try {
|
|
23
|
+
return JSON.parse(raw);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return {};
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
async function collectSignals(cwd, prompt) {
|
|
30
|
+
const [music, report, ambient, git, activity] = await Promise.allSettled([
|
|
31
|
+
getMusicSignal(),
|
|
32
|
+
getSelfReportSignal(),
|
|
33
|
+
getAmbientSignal(new Date()),
|
|
34
|
+
getGitSignal(cwd),
|
|
35
|
+
getActivitySignal(prompt),
|
|
36
|
+
]);
|
|
37
|
+
const signals = [];
|
|
38
|
+
if (music.status === "fulfilled" && music.value)
|
|
39
|
+
signals.push(music.value);
|
|
40
|
+
if (report.status === "fulfilled" && report.value)
|
|
41
|
+
signals.push(report.value);
|
|
42
|
+
if (ambient.status === "fulfilled" && ambient.value)
|
|
43
|
+
signals.push(ambient.value);
|
|
44
|
+
if (git.status === "fulfilled" && git.value)
|
|
45
|
+
signals.push(git.value);
|
|
46
|
+
if (activity.status === "fulfilled" && activity.value)
|
|
47
|
+
signals.push(activity.value);
|
|
48
|
+
return signals;
|
|
49
|
+
}
|
|
50
|
+
async function main() {
|
|
51
|
+
const { cwd, prompt } = await readStdin();
|
|
52
|
+
const projectDir = cwd ?? process.cwd();
|
|
53
|
+
const [signals, overrides] = await Promise.all([
|
|
54
|
+
Promise.race([
|
|
55
|
+
collectSignals(projectDir, prompt),
|
|
56
|
+
new Promise((resolve) => setTimeout(() => resolve([]), TOTAL_BUDGET_MS)),
|
|
57
|
+
]),
|
|
58
|
+
loadOverrides(),
|
|
59
|
+
]);
|
|
60
|
+
// Nothing to say: no signals AND no pinned dials.
|
|
61
|
+
if (signals.length === 0 && Object.keys(overrides).length === 0) {
|
|
62
|
+
process.exit(0);
|
|
63
|
+
}
|
|
64
|
+
const state = { signals, capturedAt: Date.now() };
|
|
65
|
+
const { cadence, pinned } = applyOverrides(deriveCadence(state), overrides);
|
|
66
|
+
const reframe = buildReframe(cadence);
|
|
67
|
+
const stateWithCadence = { ...state, cadence, pinned, reframe };
|
|
68
|
+
const block = render(stateWithCadence);
|
|
69
|
+
process.stdout.write(JSON.stringify({
|
|
70
|
+
hookSpecificOutput: {
|
|
71
|
+
hookEventName: "UserPromptSubmit",
|
|
72
|
+
additionalContext: block,
|
|
73
|
+
},
|
|
74
|
+
}));
|
|
75
|
+
}
|
|
76
|
+
main().catch((err) => {
|
|
77
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
78
|
+
process.stderr.write(`cadence: ${msg}\n`);
|
|
79
|
+
process.exit(0);
|
|
80
|
+
});
|
package/dist/inject.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { DIAL_WORDS } from "./cadence.js";
|
|
2
|
+
function quote(value) {
|
|
3
|
+
return JSON.stringify(value)
|
|
4
|
+
.replace(/</g, "\\u003c")
|
|
5
|
+
.replace(/>/g, "\\u003e")
|
|
6
|
+
.replace(/&/g, "\\u0026");
|
|
7
|
+
}
|
|
8
|
+
function renderMusic(m) {
|
|
9
|
+
if (!m.track)
|
|
10
|
+
return [];
|
|
11
|
+
const lines = [
|
|
12
|
+
` music: ${quote(m.track)}${m.artist ? ` — ${quote(m.artist)}` : ""}${m.player ? ` (${quote(m.player)})` : ""}`,
|
|
13
|
+
];
|
|
14
|
+
if (m.vibe)
|
|
15
|
+
lines.push(` vibe: ${m.vibe}`);
|
|
16
|
+
return lines;
|
|
17
|
+
}
|
|
18
|
+
function renderReport(r) {
|
|
19
|
+
return ` self_report: ${quote(r.text)}`;
|
|
20
|
+
}
|
|
21
|
+
function renderGit(g) {
|
|
22
|
+
// Human-readable work-state, e.g. "3 commits/hr, 5 dirty, mid-merge"
|
|
23
|
+
const parts = [
|
|
24
|
+
g.commitsLastHour > 0 ? `${g.commitsLastHour} commit${g.commitsLastHour === 1 ? "" : "s"}/hr` : null,
|
|
25
|
+
g.filesDirty > 0 ? `${g.filesDirty} dirty` : "clean tree",
|
|
26
|
+
g.minSinceLastCommit != null ? `last commit ${g.minSinceLastCommit}m ago` : null,
|
|
27
|
+
g.conflicted ? "mid-conflict" : null,
|
|
28
|
+
].filter(Boolean);
|
|
29
|
+
return ` git: ${parts.join(", ")}`;
|
|
30
|
+
}
|
|
31
|
+
function renderActivity(a) {
|
|
32
|
+
const parts = [
|
|
33
|
+
a.minSinceLastPrompt != null ? `min_since_prompt=${a.minSinceLastPrompt}` : null,
|
|
34
|
+
a.promptLength != null ? `prompt_len=${a.promptLength}` : null,
|
|
35
|
+
].filter(Boolean);
|
|
36
|
+
return ` activity: { ${parts.join(" ")} }`;
|
|
37
|
+
}
|
|
38
|
+
function renderPlace(p) {
|
|
39
|
+
const parts = [
|
|
40
|
+
p.network ? `network=${quote(p.network)}` : null,
|
|
41
|
+
p.displays != null ? `displays=${p.displays}` : null,
|
|
42
|
+
].filter(Boolean);
|
|
43
|
+
return ` place: { ${parts.join(" ")} }`;
|
|
44
|
+
}
|
|
45
|
+
function renderAmbient(a) {
|
|
46
|
+
// Human-readable atmosphere line, e.g.
|
|
47
|
+
// "friday late night, rainy, unplugged 8%, dark mode, on Home-wifi, up 14h"
|
|
48
|
+
const parts = [
|
|
49
|
+
a.isWeekend ? `${a.dayOfWeek} ${a.partOfDay}` : a.partOfDay,
|
|
50
|
+
a.weather ?? null,
|
|
51
|
+
a.onBattery === true
|
|
52
|
+
? `unplugged${a.batteryPct != null ? ` ${a.batteryPct}%` : ""}`
|
|
53
|
+
: null,
|
|
54
|
+
a.darkMode === true ? "dark mode" : null,
|
|
55
|
+
a.displays != null && a.displays > 1 ? `${a.displays} displays` : null,
|
|
56
|
+
a.network ? `on ${quote(a.network)}` : null,
|
|
57
|
+
a.loadHigh ? "machine busy" : null,
|
|
58
|
+
a.uptimeHours != null && a.uptimeHours >= 12 ? `up ${a.uptimeHours}h` : null,
|
|
59
|
+
].filter(Boolean);
|
|
60
|
+
return ` context: ${parts.join(", ")}`;
|
|
61
|
+
}
|
|
62
|
+
function renderCadence(c, pinned) {
|
|
63
|
+
const dials = Object.keys(DIAL_WORDS)
|
|
64
|
+
.map((d) => {
|
|
65
|
+
const word = DIAL_WORDS[d][c[d]];
|
|
66
|
+
return pinned.includes(d) ? `${d}=${word}*` : `${d}=${word}`;
|
|
67
|
+
})
|
|
68
|
+
.join(" ");
|
|
69
|
+
return ` { ${dials} }`;
|
|
70
|
+
}
|
|
71
|
+
export function render(state) {
|
|
72
|
+
const lines = [];
|
|
73
|
+
for (const sig of state.signals) {
|
|
74
|
+
if (sig.source === "music")
|
|
75
|
+
lines.push(...renderMusic(sig));
|
|
76
|
+
else if (sig.source === "self_report")
|
|
77
|
+
lines.push(renderReport(sig));
|
|
78
|
+
else if (sig.source === "git")
|
|
79
|
+
lines.push(renderGit(sig));
|
|
80
|
+
else if (sig.source === "activity")
|
|
81
|
+
lines.push(renderActivity(sig));
|
|
82
|
+
else if (sig.source === "place")
|
|
83
|
+
lines.push(renderPlace(sig));
|
|
84
|
+
else if (sig.source === "ambient")
|
|
85
|
+
lines.push(renderAmbient(sig));
|
|
86
|
+
}
|
|
87
|
+
// Render even with zero signals if the user pinned dials — a hand-set board
|
|
88
|
+
// is itself a signal worth injecting.
|
|
89
|
+
if (lines.length === 0 && state.pinned.length === 0)
|
|
90
|
+
return "";
|
|
91
|
+
// Evidence (signals) leads, then the dials, then the interpretation lens
|
|
92
|
+
// composed from them. `*` marks a user-pinned dial (their determination,
|
|
93
|
+
// higher authority than inference). The reframe still defers to literal words.
|
|
94
|
+
const note = state.pinned.length
|
|
95
|
+
? " # * = you set it; rest inferred from signals, advisory"
|
|
96
|
+
: " # inferred from signals, advisory";
|
|
97
|
+
return [
|
|
98
|
+
"<user_state>",
|
|
99
|
+
` signals:`,
|
|
100
|
+
...lines,
|
|
101
|
+
` cadence:${note}`,
|
|
102
|
+
renderCadence(state.cadence, state.pinned),
|
|
103
|
+
` reframe: ${state.reframe}`,
|
|
104
|
+
"</user_state>",
|
|
105
|
+
].join("\n");
|
|
106
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
const CADENCE_DIR = join(homedir(), ".cadence");
|
|
5
|
+
const ACTIVITY_FILE = join(CADENCE_DIR, "activity.json");
|
|
6
|
+
export function activityFrom(prompt, lastPromptAt, now) {
|
|
7
|
+
if (prompt == null)
|
|
8
|
+
return null;
|
|
9
|
+
const signal = {
|
|
10
|
+
source: "activity",
|
|
11
|
+
promptLength: prompt.length,
|
|
12
|
+
};
|
|
13
|
+
if (lastPromptAt != null && Number.isFinite(lastPromptAt)) {
|
|
14
|
+
signal.minSinceLastPrompt = Math.max(0, Math.round((now - lastPromptAt) / 60000));
|
|
15
|
+
}
|
|
16
|
+
return signal;
|
|
17
|
+
}
|
|
18
|
+
export async function getActivitySignal(prompt, now = Date.now()) {
|
|
19
|
+
let state = {};
|
|
20
|
+
try {
|
|
21
|
+
state = JSON.parse(await readFile(ACTIVITY_FILE, "utf-8"));
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
// no activity file yet
|
|
25
|
+
}
|
|
26
|
+
const signal = activityFrom(prompt, state.lastPromptAt, now);
|
|
27
|
+
try {
|
|
28
|
+
await mkdir(CADENCE_DIR, { recursive: true });
|
|
29
|
+
await writeFile(ACTIVITY_FILE, JSON.stringify({ lastPromptAt: now }), "utf-8");
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
// activity is best-effort; never let it break the hook
|
|
33
|
+
}
|
|
34
|
+
return signal;
|
|
35
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { exec } from "node:child_process";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { homedir, uptime, loadavg, cpus } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
// One-liner shell helper for the best-effort macOS probes. Always resolves
|
|
6
|
+
// (never throws) so a missing command can't break the hook.
|
|
7
|
+
function sh(cmd, ms = 500) {
|
|
8
|
+
return new Promise((resolve) => {
|
|
9
|
+
const child = exec(cmd, { timeout: ms, windowsHide: true }, (err, out) => resolve(err ? null : out.trim()));
|
|
10
|
+
child.on("error", () => resolve(null));
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
/* ─────────────────────────────────────────────────────────────────────────
|
|
14
|
+
* Ambient context provider — the cheapest signals, biggest reach.
|
|
15
|
+
*
|
|
16
|
+
* time + day → always available, every OS, no deps, never fails. This is
|
|
17
|
+
* the signal that makes Cadence do something for everyone.
|
|
18
|
+
* weather → opt-in: only if ~/.cadence/config.json has a location.
|
|
19
|
+
* Keyless via Open-Meteo. No silent geolocation.
|
|
20
|
+
* battery → macOS pmset, best-effort.
|
|
21
|
+
*
|
|
22
|
+
* "Put the vibes back into engineering": this is the atmosphere layer.
|
|
23
|
+
* ───────────────────────────────────────────────────────────────────────── */
|
|
24
|
+
const CONFIG_FILE = join(homedir(), ".cadence", "config.json");
|
|
25
|
+
const WEATHER_TIMEOUT_MS = 900;
|
|
26
|
+
const DAYS = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"];
|
|
27
|
+
function partOfDay(hour) {
|
|
28
|
+
if (hour < 5)
|
|
29
|
+
return "late night";
|
|
30
|
+
if (hour < 9)
|
|
31
|
+
return "early morning";
|
|
32
|
+
if (hour < 12)
|
|
33
|
+
return "morning";
|
|
34
|
+
if (hour < 14)
|
|
35
|
+
return "midday";
|
|
36
|
+
if (hour < 18)
|
|
37
|
+
return "afternoon";
|
|
38
|
+
if (hour < 22)
|
|
39
|
+
return "evening";
|
|
40
|
+
return "late night";
|
|
41
|
+
}
|
|
42
|
+
async function getBattery() {
|
|
43
|
+
if (process.platform !== "darwin")
|
|
44
|
+
return {};
|
|
45
|
+
const out = await sh("pmset -g batt", 600);
|
|
46
|
+
if (!out)
|
|
47
|
+
return {};
|
|
48
|
+
const onBattery = /Battery Power/.test(out)
|
|
49
|
+
? true
|
|
50
|
+
: /AC Power/.test(out)
|
|
51
|
+
? false
|
|
52
|
+
: undefined;
|
|
53
|
+
const pctMatch = out.match(/(\d+)%/);
|
|
54
|
+
const pct = pctMatch ? Number(pctMatch[1]) : undefined;
|
|
55
|
+
return { onBattery, pct };
|
|
56
|
+
}
|
|
57
|
+
// ── machine vitals: pure Node, cross-platform, effectively free ──────────────
|
|
58
|
+
function getVitals() {
|
|
59
|
+
const uptimeHours = Math.round((uptime() / 3600) * 10) / 10;
|
|
60
|
+
// 1-min load average relative to core count; >0.8/core ⇒ busy.
|
|
61
|
+
const cores = Math.max(1, cpus().length);
|
|
62
|
+
const load1 = loadavg()[0] ?? 0;
|
|
63
|
+
return { uptimeHours, loadHigh: load1 / cores > 0.8 };
|
|
64
|
+
}
|
|
65
|
+
// ── mac context: best-effort shell-outs, all flavor (no dial nudges) ─────────
|
|
66
|
+
async function getMacContext() {
|
|
67
|
+
if (process.platform !== "darwin")
|
|
68
|
+
return {};
|
|
69
|
+
const [dark, ssid, displays] = await Promise.all([
|
|
70
|
+
sh("defaults read -g AppleInterfaceStyle"), // "Dark", or error (=light)
|
|
71
|
+
sh("ipconfig getsummary en0 | awk -F ' SSID : ' '/ SSID : / {print $2}'", 700),
|
|
72
|
+
// fast display count via AppleScript (~100ms) — NOT system_profiler (1-3s)
|
|
73
|
+
sh(`osascript -e 'tell application "System Events" to count of desktops'`, 700),
|
|
74
|
+
]);
|
|
75
|
+
const ctx = {};
|
|
76
|
+
if (dark != null)
|
|
77
|
+
ctx.darkMode = /dark/i.test(dark);
|
|
78
|
+
if (ssid)
|
|
79
|
+
ctx.network = ssid.split("\n")[0]?.trim() || undefined;
|
|
80
|
+
const n = displays ? Number(displays) : NaN;
|
|
81
|
+
if (Number.isFinite(n) && n > 0)
|
|
82
|
+
ctx.displays = n;
|
|
83
|
+
return ctx;
|
|
84
|
+
}
|
|
85
|
+
// WMO weather codes → a single human word. Open-Meteo returns these.
|
|
86
|
+
function weatherWord(code) {
|
|
87
|
+
if (code === 0)
|
|
88
|
+
return "clear";
|
|
89
|
+
if (code <= 3)
|
|
90
|
+
return "cloudy";
|
|
91
|
+
if (code <= 48)
|
|
92
|
+
return "foggy";
|
|
93
|
+
if (code <= 67)
|
|
94
|
+
return "rainy";
|
|
95
|
+
if (code <= 77)
|
|
96
|
+
return "snowy";
|
|
97
|
+
if (code <= 82)
|
|
98
|
+
return "rainy";
|
|
99
|
+
if (code <= 86)
|
|
100
|
+
return "snowy";
|
|
101
|
+
return "stormy";
|
|
102
|
+
}
|
|
103
|
+
async function getWeather() {
|
|
104
|
+
let cfg;
|
|
105
|
+
try {
|
|
106
|
+
cfg = JSON.parse(await readFile(CONFIG_FILE, "utf-8"));
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return undefined; // no config → weather is simply off
|
|
110
|
+
}
|
|
111
|
+
const loc = cfg.location;
|
|
112
|
+
if (!loc || typeof loc.lat !== "number" || typeof loc.lon !== "number")
|
|
113
|
+
return undefined;
|
|
114
|
+
const ctrl = new AbortController();
|
|
115
|
+
const timer = setTimeout(() => ctrl.abort(), WEATHER_TIMEOUT_MS);
|
|
116
|
+
try {
|
|
117
|
+
const url = `https://api.open-meteo.com/v1/forecast?latitude=${loc.lat}` +
|
|
118
|
+
`&longitude=${loc.lon}¤t=weather_code`;
|
|
119
|
+
const res = await fetch(url, { signal: ctrl.signal });
|
|
120
|
+
if (!res.ok)
|
|
121
|
+
return undefined;
|
|
122
|
+
const data = (await res.json());
|
|
123
|
+
const code = data.current?.weather_code;
|
|
124
|
+
return typeof code === "number" ? weatherWord(code) : undefined;
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
finally {
|
|
130
|
+
clearTimeout(timer);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
export async function getAmbientSignal(now) {
|
|
134
|
+
const hour = now.getHours();
|
|
135
|
+
const vitals = getVitals(); // sync, free
|
|
136
|
+
// all probes run in parallel; each resolves to a safe default on failure.
|
|
137
|
+
const [weather, battery, mac] = await Promise.all([
|
|
138
|
+
getWeather(),
|
|
139
|
+
getBattery(),
|
|
140
|
+
getMacContext(),
|
|
141
|
+
]);
|
|
142
|
+
return {
|
|
143
|
+
source: "ambient",
|
|
144
|
+
partOfDay: partOfDay(hour),
|
|
145
|
+
dayOfWeek: DAYS[now.getDay()] ?? "",
|
|
146
|
+
isWeekend: now.getDay() === 0 || now.getDay() === 6,
|
|
147
|
+
hour,
|
|
148
|
+
weather,
|
|
149
|
+
onBattery: battery.onBattery,
|
|
150
|
+
batteryPct: battery.pct,
|
|
151
|
+
uptimeHours: vitals.uptimeHours,
|
|
152
|
+
loadHigh: vitals.loadHigh,
|
|
153
|
+
focus: mac.focus,
|
|
154
|
+
displays: mac.displays,
|
|
155
|
+
network: mac.network,
|
|
156
|
+
darkMode: mac.darkMode,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { exec } from "node:child_process";
|
|
2
|
+
/* ─────────────────────────────────────────────────────────────────────────
|
|
3
|
+
* git work-state — the honest "what are you actually doing" signal.
|
|
4
|
+
*
|
|
5
|
+
* Cross-platform (git is git everywhere). Reads the repo at `cwd` — which,
|
|
6
|
+
* when run from the hook, is the project Claude Code is working in.
|
|
7
|
+
*
|
|
8
|
+
* Rendered as flavor for now: commits this hour, dirty files, and whether
|
|
9
|
+
* you're mid-merge/rebase (the real debug tell). No dial nudges yet — we
|
|
10
|
+
* watch the output first, then decide what should steer.
|
|
11
|
+
* ───────────────────────────────────────────────────────────────────────── */
|
|
12
|
+
const GIT_TIMEOUT_MS = 700;
|
|
13
|
+
function git(args, cwd) {
|
|
14
|
+
return new Promise((resolve) => {
|
|
15
|
+
const child = exec(`git ${args}`, { cwd, timeout: GIT_TIMEOUT_MS, windowsHide: true }, (err, stdout) => resolve(err ? null : stdout.trim()));
|
|
16
|
+
child.on("error", () => resolve(null));
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
export async function getGitSignal(cwd = process.cwd()) {
|
|
20
|
+
// Bail fast if this isn't a repo — cheapest possible check.
|
|
21
|
+
const inRepo = await git("rev-parse --is-inside-work-tree", cwd);
|
|
22
|
+
if (inRepo !== "true")
|
|
23
|
+
return null;
|
|
24
|
+
const [statusOut, logOut, mergeHead, rebaseDir] = await Promise.all([
|
|
25
|
+
git("status --porcelain", cwd),
|
|
26
|
+
// commit timestamps (unix) in the last hour
|
|
27
|
+
git('log --since="1 hour ago" --format=%ct', cwd),
|
|
28
|
+
git("rev-parse --verify -q MERGE_HEAD", cwd), // non-null ⇒ mid-merge
|
|
29
|
+
git("rev-parse --git-path rebase-merge", cwd), // path exists ⇒ mid-rebase
|
|
30
|
+
]);
|
|
31
|
+
const filesDirty = statusOut ? statusOut.split("\n").filter(Boolean).length : 0;
|
|
32
|
+
const commitsLastHour = logOut ? logOut.split("\n").filter(Boolean).length : 0;
|
|
33
|
+
// conflict markers in `status --porcelain`: UU, AA, DD, AU, UA, DU, UD
|
|
34
|
+
const conflicted = mergeHead != null ||
|
|
35
|
+
(statusOut != null && /^(UU|AA|DD|AU|UA|DU|UD) /m.test(statusOut));
|
|
36
|
+
// minutes since most recent commit, if any in the window
|
|
37
|
+
let minSinceLastCommit;
|
|
38
|
+
if (logOut) {
|
|
39
|
+
const newest = Math.max(...logOut.split("\n").filter(Boolean).map(Number));
|
|
40
|
+
if (Number.isFinite(newest)) {
|
|
41
|
+
minSinceLastCommit = Math.round((Date.now() / 1000 - newest) / 60);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
void rebaseDir; // reserved: rebase detection refinement (see BACKLOG)
|
|
45
|
+
return {
|
|
46
|
+
source: "git",
|
|
47
|
+
commitsLastHour,
|
|
48
|
+
minSinceLastCommit,
|
|
49
|
+
filesDirty,
|
|
50
|
+
conflicted,
|
|
51
|
+
};
|
|
52
|
+
}
|