@cullumco/cadence 0.1.1 → 0.1.3
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 +63 -15
- package/dist/cadence.js +12 -6
- package/dist/cli.js +17 -0
- package/dist/inject.js +1 -0
- package/dist/posttool.js +119 -0
- package/dist/providers/ambient.js +82 -3
- package/dist/providers/selfreport.js +1 -1
- package/dist/signals-view.js +103 -0
- package/dist/vibe.js +64 -2
- 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.3",
|
|
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,7 +9,10 @@ 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
|
-
|
|
12
|
+
**macOS-only (alpha).** Most signals read the Mac around you; other platforms
|
|
13
|
+
degrade to self-report + dials + time/git.
|
|
14
|
+
|
|
15
|
+
A [Cullum&Co](https://cullum.co) project · [cullumco.github.io/cadence](https://cullumco.github.io/cadence/)
|
|
13
16
|
|
|
14
17
|
## What it does
|
|
15
18
|
|
|
@@ -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
|
|
@@ -51,8 +78,10 @@ defers to what you actually typed.
|
|
|
51
78
|
Spotify login, no API key, no Premium.
|
|
52
79
|
- **self-report** — what you tell it: `cadence state "two beers, shipping"`.
|
|
53
80
|
|
|
54
|
-
Time/day
|
|
55
|
-
|
|
81
|
+
Time/day, self-report, and git move the dials (git reads *what you're
|
|
82
|
+
doing*: 3+ commits/hr → fast pace, mid-conflict → verify-first); the rest
|
|
83
|
+
render as context the agent reads (flavor). Self-report always outranks
|
|
84
|
+
inference — "I'm shipping" beats a mid-conflict read.
|
|
56
85
|
2. **Dials** — four independent knobs, each `low | medium | high`, inferred from
|
|
57
86
|
the signals (or pinned by you):
|
|
58
87
|
- **pace** — deliberate ↔ fast
|
|
@@ -69,9 +98,11 @@ express.
|
|
|
69
98
|
|
|
70
99
|
## Requirements
|
|
71
100
|
|
|
72
|
-
- **macOS
|
|
73
|
-
|
|
74
|
-
|
|
101
|
+
- **macOS.** Cadence is mac-only for the alpha: music (AppleScript
|
|
102
|
+
now-playing), battery, dark mode, displays, wifi, and Focus/DND all read the
|
|
103
|
+
Mac around you. On other platforms it still runs — self-report, dials,
|
|
104
|
+
time/day, and git work anywhere, the rest degrade silently — but the product
|
|
105
|
+
is built for the Mac.
|
|
75
106
|
- **Node 20+**
|
|
76
107
|
- Claude Code for the alpha adapter
|
|
77
108
|
|
|
@@ -114,8 +145,14 @@ cadence state "two beers, shipping" # set self-reported state (expires in 4h)
|
|
|
114
145
|
cadence state # print current self-report
|
|
115
146
|
cadence clear # clear it
|
|
116
147
|
cadence test # preview exactly what the hook would inject
|
|
148
|
+
cadence signals # every signal — live value, or why it's absent
|
|
117
149
|
```
|
|
118
150
|
|
|
151
|
+
`cadence signals` is the legibility view: it never goes silent. Every signal
|
|
152
|
+
Cadence knows how to read is listed with its live value, or the exact reason
|
|
153
|
+
it's absent — opt-in not taken, below a render threshold, missing permission
|
|
154
|
+
(Focus needs Full Disk Access), or platform-gated.
|
|
155
|
+
|
|
119
156
|
From inside Claude Code, the plugin skill gives the same self-report path:
|
|
120
157
|
|
|
121
158
|
```text
|
|
@@ -184,17 +221,28 @@ the gate to run on every push to `main`.
|
|
|
184
221
|
|
|
185
222
|
See [`BACKLOG.md`](BACKLOG.md). Highlights:
|
|
186
223
|
|
|
187
|
-
- **
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
- **
|
|
191
|
-
|
|
224
|
+
- **Git nudges** — the highest-value next step: built but dormant, they move
|
|
225
|
+
the dials from *what you said* to *what you're actually doing*.
|
|
226
|
+
- **More signals** — candidates on the bench:
|
|
227
|
+
- **calendar density** — a meeting in 20 minutes should read as `pace=fast,
|
|
228
|
+
posture=decisive`; a clear afternoon as room to explore.
|
|
229
|
+
- **typing tempo** — prompt rhythm beyond length: rapid-fire short prompts vs.
|
|
230
|
+
one long considered one.
|
|
231
|
+
- **focused app** — what's frontmost next to the terminal (docs? a profiler?
|
|
232
|
+
Slack?).
|
|
233
|
+
- **deeper Focus** — manual + scheduled Focus detection ship now; geofenced/
|
|
234
|
+
iPhone-synced Focus leaves no local trace and stays undetectable.
|
|
235
|
+
- **After-the-fact injection** — the first cut ships: a `PostToolUse` hook
|
|
236
|
+
watches git-ish commands and speaks exactly once when the repo enters or
|
|
237
|
+
leaves a merge/rebase conflict ("this is debug now" / "conflict resolved").
|
|
238
|
+
Next material events: failing-test transitions, reset/force-push thrash.
|
|
192
239
|
- **Opt-in flavor providers** — horoscope, moon phase, for those who want them.
|
|
193
240
|
|
|
194
241
|
## Caveats
|
|
195
242
|
|
|
196
|
-
- **macOS-only
|
|
197
|
-
|
|
243
|
+
- **macOS-only.** The alpha targets the Mac: music, battery, dark mode,
|
|
244
|
+
displays, wifi, and Focus are all macOS probes. Other platforms get
|
|
245
|
+
self-report + dials + time/git; everything else degrades silently.
|
|
198
246
|
- **Spotify's Web API is not used** and not needed — Spotify deprecated audio
|
|
199
247
|
features for new apps (2024) and gated dev-mode behind Premium (2026). Cadence
|
|
200
248
|
reads what's playing at the OS level instead.
|
package/dist/cadence.js
CHANGED
|
@@ -71,6 +71,16 @@ export function deriveCadence(state) {
|
|
|
71
71
|
if (music?.vibe && /\b(calm|chilled|ethereal|romantic|warm)\b/.test(music.vibe)) {
|
|
72
72
|
c.tone = "low";
|
|
73
73
|
}
|
|
74
|
+
// ── git → pace / proactivity (what you're DOING, not what you said) ───────
|
|
75
|
+
// Enabled 2026-06-05 after the flavor proved trustworthy in real use.
|
|
76
|
+
// Applied below self-report on purpose: "I'm shipping" beats a mid-conflict
|
|
77
|
+
// read — the user's explicit word stays the higher authority.
|
|
78
|
+
if (git) {
|
|
79
|
+
if (git.commitsLastHour >= 3)
|
|
80
|
+
c.pace = "high"; // flow state
|
|
81
|
+
if (git.conflicted)
|
|
82
|
+
c.proactivity = "low"; // verify, don't barrel
|
|
83
|
+
}
|
|
74
84
|
// ── self-report → posture / proactivity / tone (you know your state) ──────
|
|
75
85
|
if (report) {
|
|
76
86
|
const t = report.text.toLowerCase();
|
|
@@ -92,12 +102,8 @@ export function deriveCadence(state) {
|
|
|
92
102
|
if (/\b(focused|formal|work|serious|crunch)\b/.test(t))
|
|
93
103
|
c.tone = "high";
|
|
94
104
|
}
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
// conflicted → proactivity low (verify, don't barrel)
|
|
98
|
-
// commitsLastHour >= 3 → pace high (flow state)
|
|
99
|
-
// See BACKLOG: turn these on once the flavor proves trustworthy.
|
|
100
|
-
void git;
|
|
105
|
+
// Still-dormant candidate nudges (see BACKLOG):
|
|
106
|
+
// ambient focus on → proactivity high (heads-down = fewer check-ins)
|
|
101
107
|
// ── activity → pace (returning from a break = slow back down) ─────────────
|
|
102
108
|
if (activity?.minSinceLastPrompt != null && activity.minSinceLastPrompt > 30) {
|
|
103
109
|
c.pace = "low";
|
package/dist/cli.js
CHANGED
|
@@ -8,6 +8,7 @@ import { getAmbientSignal } from "./providers/ambient.js";
|
|
|
8
8
|
import { getGitSignal } from "./providers/git.js";
|
|
9
9
|
import { deriveCadence, buildReframe, loadOverrides, applyOverrides, resolveDialLevel, DIALS, DIAL_WORDS, } from "./cadence.js";
|
|
10
10
|
import { render } from "./inject.js";
|
|
11
|
+
import { renderSignalsTable } from "./signals-view.js";
|
|
11
12
|
const CADENCE_DIR = join(homedir(), ".cadence");
|
|
12
13
|
const STATE_FILE = join(CADENCE_DIR, "state.txt");
|
|
13
14
|
const CONFIG_FILE = join(CADENCE_DIR, "config.json");
|
|
@@ -66,6 +67,19 @@ async function cmdTest() {
|
|
|
66
67
|
}
|
|
67
68
|
console.log("\n" + block + "\n");
|
|
68
69
|
}
|
|
70
|
+
// The legibility view: every signal Cadence can read — live value, or the
|
|
71
|
+
// reason it's absent. Unlike `test`, this never goes silent.
|
|
72
|
+
async function cmdSignals() {
|
|
73
|
+
const [music, report, ambient, git] = await Promise.all([
|
|
74
|
+
getMusicSignal().catch(() => null),
|
|
75
|
+
getSelfReportSignal().catch(() => null),
|
|
76
|
+
getAmbientSignal(new Date()).catch(() => null),
|
|
77
|
+
getGitSignal(process.cwd()).catch(() => null),
|
|
78
|
+
]);
|
|
79
|
+
console.log("\n" +
|
|
80
|
+
renderSignalsTable({ music, report, ambient, git, now: Date.now(), platform: process.platform }) +
|
|
81
|
+
"\n");
|
|
82
|
+
}
|
|
69
83
|
const LEVELS = ["low", "medium", "high"];
|
|
70
84
|
async function cmdSet(args) {
|
|
71
85
|
const [dial, value] = args;
|
|
@@ -270,6 +284,7 @@ const HELP = `
|
|
|
270
284
|
cadence state print current self-reported state
|
|
271
285
|
cadence clear clear self-reported state
|
|
272
286
|
cadence test preview what the hook would inject right now
|
|
287
|
+
cadence signals every signal — live value, or why it's absent
|
|
273
288
|
|
|
274
289
|
dials (your determination — pinned dials override inference):
|
|
275
290
|
cadence dials show the mixing board and what's pinned
|
|
@@ -292,6 +307,8 @@ async function main() {
|
|
|
292
307
|
return cmdClear();
|
|
293
308
|
case "test":
|
|
294
309
|
return cmdTest();
|
|
310
|
+
case "signals":
|
|
311
|
+
return cmdSignals();
|
|
295
312
|
case "set":
|
|
296
313
|
return cmdSet(rest);
|
|
297
314
|
case "unset":
|
package/dist/inject.js
CHANGED
|
@@ -51,6 +51,7 @@ function renderAmbient(a) {
|
|
|
51
51
|
a.onBattery === true
|
|
52
52
|
? `unplugged${a.batteryPct != null ? ` ${a.batteryPct}%` : ""}`
|
|
53
53
|
: null,
|
|
54
|
+
a.focus === true ? "focus on" : null,
|
|
54
55
|
a.darkMode === true ? "dark mode" : null,
|
|
55
56
|
a.displays != null && a.displays > 1 ? `${a.displays} displays` : null,
|
|
56
57
|
a.network ? `on ${quote(a.network)}` : null,
|
package/dist/posttool.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { pathToFileURL } from "node:url";
|
|
3
|
+
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { getGitSignal } from "./providers/git.js";
|
|
7
|
+
/* ─────────────────────────────────────────────────────────────────────────
|
|
8
|
+
* PostToolUse adapter — V2 "after-the-fact" refinement, conservative cut.
|
|
9
|
+
*
|
|
10
|
+
* The UserPromptSubmit lens is predictive: it reads AMBIENT state before any
|
|
11
|
+
* work happens. This hook watches the work itself and speaks only when the
|
|
12
|
+
* observed work MATERIALLY changes the cadence read. V2's single material
|
|
13
|
+
* event: the repo entering or leaving a merge/rebase conflict — the
|
|
14
|
+
* strongest debug tell we have, far stronger than music ever was.
|
|
15
|
+
*
|
|
16
|
+
* Discipline (the BACKLOG's hard constraint):
|
|
17
|
+
* - fires only after Bash tool calls whose command mentions git
|
|
18
|
+
* - injects at most once per conflict-state TRANSITION, never per tool
|
|
19
|
+
* - silent in every other case, silent on every error
|
|
20
|
+
* ───────────────────────────────────────────────────────────────────────── */
|
|
21
|
+
const TOTAL_BUDGET_MS = 1500;
|
|
22
|
+
const STATE_FILE = join(homedir(), ".cadence", "workstate.json");
|
|
23
|
+
const MAX_SESSIONS = 20;
|
|
24
|
+
// Gate 1: is this tool call even capable of changing git conflict state?
|
|
25
|
+
// Only Bash commands that mention git — Edit/Read/etc. can't start a merge,
|
|
26
|
+
// and checking the repo after every tool call would betray the silence rule.
|
|
27
|
+
export function shouldCheck(input) {
|
|
28
|
+
if (input.tool_name !== "Bash")
|
|
29
|
+
return false;
|
|
30
|
+
const cmd = input.tool_input?.command;
|
|
31
|
+
return typeof cmd === "string" && /\bgit\b/.test(cmd);
|
|
32
|
+
}
|
|
33
|
+
// Gate 2: did the observed state actually TRANSITION? Speak only on the edge.
|
|
34
|
+
// undefined → true first observation reveals a conflict → speak
|
|
35
|
+
// false → true work just entered a conflict → speak
|
|
36
|
+
// true → false conflict resolved → speak (release)
|
|
37
|
+
// anything else no change → silent
|
|
38
|
+
export function refineContext(prev, conflicted) {
|
|
39
|
+
if (prev === conflicted)
|
|
40
|
+
return null;
|
|
41
|
+
if (conflicted) {
|
|
42
|
+
return ("<user_state_update>observed work: the repo just entered a merge/rebase " +
|
|
43
|
+
"conflict. Read the cadence as debug now — verify the repo state, lead " +
|
|
44
|
+
"with hypotheses, and don't barrel toward shipping until it's resolved. " +
|
|
45
|
+
"If the user's words clearly mean otherwise, follow their words.</user_state_update>");
|
|
46
|
+
}
|
|
47
|
+
if (prev === true) {
|
|
48
|
+
return ("<user_state_update>observed work: the conflict is resolved — drop the " +
|
|
49
|
+
"debug framing and return to the user's prior cadence.</user_state_update>");
|
|
50
|
+
}
|
|
51
|
+
return null; // first observation of a clean repo: record silently
|
|
52
|
+
}
|
|
53
|
+
async function loadState() {
|
|
54
|
+
try {
|
|
55
|
+
const raw = JSON.parse(await readFile(STATE_FILE, "utf-8"));
|
|
56
|
+
return raw && typeof raw === "object" ? raw : {};
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return {};
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
async function saveState(state) {
|
|
63
|
+
try {
|
|
64
|
+
// prune to the newest MAX_SESSIONS so the file can't grow unbounded
|
|
65
|
+
const entries = Object.entries(state)
|
|
66
|
+
.sort((a, b) => b[1].at - a[1].at)
|
|
67
|
+
.slice(0, MAX_SESSIONS);
|
|
68
|
+
await mkdir(join(homedir(), ".cadence"), { recursive: true });
|
|
69
|
+
await writeFile(STATE_FILE, JSON.stringify(Object.fromEntries(entries)), "utf-8");
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
// best-effort; a failed save just means we might speak twice
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
async function readStdin() {
|
|
76
|
+
if (process.stdin.isTTY)
|
|
77
|
+
return {};
|
|
78
|
+
let raw = "";
|
|
79
|
+
for await (const chunk of process.stdin)
|
|
80
|
+
raw += chunk;
|
|
81
|
+
try {
|
|
82
|
+
return JSON.parse(raw);
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return {};
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
async function main() {
|
|
89
|
+
const input = await readStdin();
|
|
90
|
+
if (!shouldCheck(input))
|
|
91
|
+
return;
|
|
92
|
+
const git = await Promise.race([
|
|
93
|
+
getGitSignal(input.cwd ?? process.cwd()).catch(() => null),
|
|
94
|
+
new Promise((resolve) => setTimeout(() => resolve(null), TOTAL_BUDGET_MS)),
|
|
95
|
+
]);
|
|
96
|
+
if (!git)
|
|
97
|
+
return; // not a repo / git unavailable → nothing to observe
|
|
98
|
+
const key = input.session_id ?? "default";
|
|
99
|
+
const state = await loadState();
|
|
100
|
+
const prev = state[key]?.conflicted;
|
|
101
|
+
const message = refineContext(prev, git.conflicted);
|
|
102
|
+
state[key] = { conflicted: git.conflicted, at: Date.now() };
|
|
103
|
+
await saveState(state);
|
|
104
|
+
if (message) {
|
|
105
|
+
process.stdout.write(JSON.stringify({
|
|
106
|
+
hookSpecificOutput: {
|
|
107
|
+
hookEventName: "PostToolUse",
|
|
108
|
+
additionalContext: message,
|
|
109
|
+
},
|
|
110
|
+
}));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
114
|
+
main().catch((err) => {
|
|
115
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
116
|
+
process.stderr.write(`cadence posttool: ${msg}\n`);
|
|
117
|
+
process.exit(0); // never block the tool loop
|
|
118
|
+
});
|
|
119
|
+
}
|
|
@@ -22,6 +22,9 @@ 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_DIR = join(homedir(), "Library", "DoNotDisturb", "DB");
|
|
26
|
+
const DND_ASSERTIONS = join(DND_DIR, "Assertions.json");
|
|
27
|
+
const DND_MODE_CONFIGS = join(DND_DIR, "ModeConfigurations.json");
|
|
25
28
|
const WEATHER_TIMEOUT_MS = 900;
|
|
26
29
|
const DAYS = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"];
|
|
27
30
|
function partOfDay(hour) {
|
|
@@ -62,19 +65,95 @@ function getVitals() {
|
|
|
62
65
|
const load1 = loadavg()[0] ?? 0;
|
|
63
66
|
return { uptimeHours, loadHigh: load1 / cores > 0.8 };
|
|
64
67
|
}
|
|
68
|
+
// Is any SCHEDULED Focus window active right now? Pure function over the
|
|
69
|
+
// parsed ModeConfigurations.json (exported for fixture tests). The shape is
|
|
70
|
+
// Apple-private but stable Monterey→Tahoe: each mode's triggers carry
|
|
71
|
+
// enabledSetting (2 = on) and a start/end time-of-day window that may wrap
|
|
72
|
+
// midnight (22:00 → 07:00). Anything unexpected reads as "not active".
|
|
73
|
+
export function scheduleActive(json, now) {
|
|
74
|
+
try {
|
|
75
|
+
const configs = json?.data?.[0]?.modeConfigurations;
|
|
76
|
+
if (!configs || typeof configs !== "object")
|
|
77
|
+
return false;
|
|
78
|
+
const minutes = now.getHours() * 60 + now.getMinutes();
|
|
79
|
+
for (const mode of Object.values(configs)) {
|
|
80
|
+
const triggers = mode?.triggers?.triggers;
|
|
81
|
+
if (!Array.isArray(triggers))
|
|
82
|
+
continue;
|
|
83
|
+
for (const t of triggers) {
|
|
84
|
+
const trig = t;
|
|
85
|
+
if (trig?.enabledSetting !== 2)
|
|
86
|
+
continue; // 2 = schedule enabled
|
|
87
|
+
const { timePeriodStartTimeHour: sh, timePeriodEndTimeHour: eh } = trig;
|
|
88
|
+
if (typeof sh !== "number" || typeof eh !== "number")
|
|
89
|
+
continue;
|
|
90
|
+
const start = sh * 60 + (trig.timePeriodStartTimeMinute ?? 0);
|
|
91
|
+
const end = eh * 60 + (trig.timePeriodEndTimeMinute ?? 0);
|
|
92
|
+
const active = start < end
|
|
93
|
+
? minutes >= start && minutes < end
|
|
94
|
+
: minutes >= start || minutes < end; // window wraps midnight
|
|
95
|
+
if (active)
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
// Focus / DND — tri-state, read straight from the private donotdisturbd DB
|
|
106
|
+
// (~1ms, no subprocess). Exported for the darwin smoke test.
|
|
107
|
+
// true → a Focus mode is asserted (manual toggle) OR a scheduled
|
|
108
|
+
// Focus window is active right now
|
|
109
|
+
// false → assertions read OK, none set, no schedule active → off
|
|
110
|
+
// undefined → unreadable: terminal lacks Full Disk Access (TCC denies the
|
|
111
|
+
// read silently — hook subprocesses never get a prompt), file
|
|
112
|
+
// moved, or shape changed → "unavailable", never "off"
|
|
113
|
+
// Remaining gap: geofenced/iPhone-synced Focus writes neither an assertion
|
|
114
|
+
// nor a local schedule — undetectable from this Mac.
|
|
115
|
+
export async function getFocus(now = new Date()) {
|
|
116
|
+
if (process.platform !== "darwin")
|
|
117
|
+
return undefined;
|
|
118
|
+
let manual;
|
|
119
|
+
try {
|
|
120
|
+
const raw = await readFile(DND_ASSERTIONS, "utf-8");
|
|
121
|
+
const json = JSON.parse(raw);
|
|
122
|
+
const records = json.data?.[0]?.storeAssertionRecords;
|
|
123
|
+
manual = Array.isArray(records) && records.length > 0;
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
manual = undefined;
|
|
127
|
+
}
|
|
128
|
+
if (manual)
|
|
129
|
+
return true;
|
|
130
|
+
// Manual focus is off (or unknowable) — a scheduled window may still be on.
|
|
131
|
+
try {
|
|
132
|
+
const raw = await readFile(DND_MODE_CONFIGS, "utf-8");
|
|
133
|
+
if (scheduleActive(JSON.parse(raw), now))
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
// both files unreadable → truly unknown
|
|
138
|
+
}
|
|
139
|
+
return manual;
|
|
140
|
+
}
|
|
65
141
|
// ── mac context: best-effort shell-outs, all flavor (no dial nudges) ─────────
|
|
66
142
|
async function getMacContext() {
|
|
67
143
|
if (process.platform !== "darwin")
|
|
68
144
|
return {};
|
|
69
|
-
const [dark, ssid, displays] = await Promise.all([
|
|
145
|
+
const [dark, ssid, displays, focus] = await Promise.all([
|
|
70
146
|
sh("defaults read -g AppleInterfaceStyle"), // "Dark", or error (=light)
|
|
71
147
|
sh("ipconfig getsummary en0 | awk -F ' SSID : ' '/ SSID : / {print $2}'", 700),
|
|
72
148
|
// fast display count via AppleScript (~100ms) — NOT system_profiler (1-3s)
|
|
73
149
|
sh(`osascript -e 'tell application "System Events" to count of desktops'`, 700),
|
|
150
|
+
getFocus(),
|
|
74
151
|
]);
|
|
75
152
|
const ctx = {};
|
|
76
|
-
|
|
77
|
-
|
|
153
|
+
// `defaults read` exits non-zero when the key is unset — which is exactly
|
|
154
|
+
// what light mode looks like. So error/null ⇒ light, not unknown.
|
|
155
|
+
ctx.darkMode = dark != null && /dark/i.test(dark);
|
|
156
|
+
ctx.focus = focus;
|
|
78
157
|
if (ssid)
|
|
79
158
|
ctx.network = ssid.split("\n")[0]?.trim() || undefined;
|
|
80
159
|
const n = displays ? Number(displays) : NaN;
|
|
@@ -2,7 +2,7 @@ import { readFile, stat } from "node:fs/promises";
|
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
const STATE_FILE = join(homedir(), ".cadence", "state.txt");
|
|
5
|
-
const STALE_AFTER_MS = 4 * 60 * 60 * 1000;
|
|
5
|
+
export const STALE_AFTER_MS = 4 * 60 * 60 * 1000;
|
|
6
6
|
export async function getSelfReportSignal() {
|
|
7
7
|
try {
|
|
8
8
|
const [text, info] = await Promise.all([readFile(STATE_FILE, "utf-8"), stat(STATE_FILE)]);
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { STALE_AFTER_MS } from "./providers/selfreport.js";
|
|
2
|
+
const LABEL_W = 12; // sub-row label column
|
|
3
|
+
const VALUE_W = 18; // value column, before a "(hidden: …)" note
|
|
4
|
+
function row(label, value, note) {
|
|
5
|
+
const v = note ? `${value.padEnd(VALUE_W)} ${note}` : value;
|
|
6
|
+
return ` ${label.padEnd(LABEL_W)}${v}`;
|
|
7
|
+
}
|
|
8
|
+
function top(label, value) {
|
|
9
|
+
return ` ${label.padEnd(LABEL_W + 2)}${value}`;
|
|
10
|
+
}
|
|
11
|
+
function ttlLeft(setAt, now) {
|
|
12
|
+
const rem = Math.max(0, STALE_AFTER_MS - (now - setAt));
|
|
13
|
+
const h = Math.floor(rem / 3_600_000);
|
|
14
|
+
const m = Math.floor((rem % 3_600_000) / 60_000);
|
|
15
|
+
return h > 0 ? `${h}h${String(m).padStart(2, "0")}m left` : `${m}m left`;
|
|
16
|
+
}
|
|
17
|
+
function ambientRows(a, platform) {
|
|
18
|
+
if (!a)
|
|
19
|
+
return [top("ambient", "— unavailable")];
|
|
20
|
+
const mac = platform === "darwin";
|
|
21
|
+
const macNote = "— macOS only";
|
|
22
|
+
const lines = [" ambient"];
|
|
23
|
+
lines.push(row("time", `${a.partOfDay} (${a.dayOfWeek})`));
|
|
24
|
+
lines.push(row("weather", a.weather ?? "— off (run: cadence set-location <lat> <lon>)"));
|
|
25
|
+
lines.push(!mac
|
|
26
|
+
? row("battery", macNote)
|
|
27
|
+
: a.onBattery == null
|
|
28
|
+
? row("battery", "— unavailable")
|
|
29
|
+
: a.onBattery
|
|
30
|
+
? row("battery", `unplugged${a.batteryPct != null ? `, ${a.batteryPct}%` : ""}`)
|
|
31
|
+
: row("battery", `plugged in${a.batteryPct != null ? `, ${a.batteryPct}%` : ""}`, "(hidden: only shows unplugged)"));
|
|
32
|
+
lines.push(!mac
|
|
33
|
+
? row("dark mode", macNote)
|
|
34
|
+
: a.darkMode == null
|
|
35
|
+
? row("dark mode", "— unavailable")
|
|
36
|
+
: a.darkMode
|
|
37
|
+
? row("dark mode", "on")
|
|
38
|
+
: row("dark mode", "off", "(hidden: only shows on)"));
|
|
39
|
+
lines.push(!mac
|
|
40
|
+
? row("displays", macNote)
|
|
41
|
+
: a.displays == null
|
|
42
|
+
? row("displays", "— unavailable")
|
|
43
|
+
: a.displays > 1
|
|
44
|
+
? row("displays", String(a.displays))
|
|
45
|
+
: row("displays", String(a.displays), "(hidden: only shows >1)"));
|
|
46
|
+
lines.push(!mac
|
|
47
|
+
? row("wifi", macNote)
|
|
48
|
+
: a.network
|
|
49
|
+
? row("wifi", JSON.stringify(a.network))
|
|
50
|
+
: row("wifi", "— unavailable"));
|
|
51
|
+
lines.push(a.uptimeHours == null
|
|
52
|
+
? row("uptime", "— unavailable")
|
|
53
|
+
: a.uptimeHours >= 12
|
|
54
|
+
? row("uptime", `${a.uptimeHours}h`)
|
|
55
|
+
: row("uptime", `${a.uptimeHours}h`, "(hidden: only shows ≥12h)"));
|
|
56
|
+
lines.push(a.loadHigh
|
|
57
|
+
? row("load", "high (machine busy)")
|
|
58
|
+
: row("load", "normal", "(hidden: only shows high)"));
|
|
59
|
+
lines.push(!mac
|
|
60
|
+
? row("focus", macNote)
|
|
61
|
+
: a.focus == null
|
|
62
|
+
? row("focus", "— unavailable (terminal needs Full Disk Access)")
|
|
63
|
+
: a.focus
|
|
64
|
+
? row("focus", "on")
|
|
65
|
+
: row("focus", "off", "(hidden: only shows on)"));
|
|
66
|
+
return lines;
|
|
67
|
+
}
|
|
68
|
+
function musicRows(m) {
|
|
69
|
+
if (!m?.track)
|
|
70
|
+
return [top("music", "— nothing playing")];
|
|
71
|
+
const lines = [" music"];
|
|
72
|
+
lines.push(row("track", `${JSON.stringify(m.track)}${m.artist ? ` — ${m.artist}` : ""}${m.player ? ` (${m.player})` : ""}`));
|
|
73
|
+
lines.push(m.vibe ? row("vibe", m.vibe) : row("vibe", "— no tags yet (looked up once per artist)"));
|
|
74
|
+
return lines;
|
|
75
|
+
}
|
|
76
|
+
function reportRow(r, now) {
|
|
77
|
+
if (!r)
|
|
78
|
+
return top("self_report", '— none set (run: cadence state "...")');
|
|
79
|
+
const text = r.text.length > 44 ? `${r.text.slice(0, 43)}…` : r.text;
|
|
80
|
+
return top("self_report", `${JSON.stringify(text)} (${ttlLeft(r.setAt, now)})`);
|
|
81
|
+
}
|
|
82
|
+
function gitRow(g) {
|
|
83
|
+
if (!g)
|
|
84
|
+
return top("git", "— not a git repo (signal is per-directory)");
|
|
85
|
+
const parts = [
|
|
86
|
+
g.commitsLastHour > 0
|
|
87
|
+
? `${g.commitsLastHour} commit${g.commitsLastHour === 1 ? "" : "s"}/hr`
|
|
88
|
+
: null,
|
|
89
|
+
g.filesDirty > 0 ? `${g.filesDirty} dirty` : "clean tree",
|
|
90
|
+
g.minSinceLastCommit != null ? `last commit ${g.minSinceLastCommit}m ago` : null,
|
|
91
|
+
g.conflicted ? "mid-conflict" : null,
|
|
92
|
+
].filter(Boolean);
|
|
93
|
+
return top("git", parts.join(", "));
|
|
94
|
+
}
|
|
95
|
+
export function renderSignalsTable(raw) {
|
|
96
|
+
return [
|
|
97
|
+
...ambientRows(raw.ambient, raw.platform),
|
|
98
|
+
...musicRows(raw.music),
|
|
99
|
+
reportRow(raw.report, raw.now),
|
|
100
|
+
gitRow(raw.git),
|
|
101
|
+
top("activity", "— session-only (the hook injects it per-prompt)"),
|
|
102
|
+
].join("\n");
|
|
103
|
+
}
|
package/dist/vibe.js
CHANGED
|
@@ -37,11 +37,55 @@ const GENRE_AFFECT = {
|
|
|
37
37
|
pop: { energy: 0.72, valence: 0.78, acoustic: 0.15, moods: ["happy", "uplifting"] },
|
|
38
38
|
funk: { energy: 0.75, valence: 0.8, acoustic: 0.1, moods: ["happy", "energetic"] },
|
|
39
39
|
disco: { energy: 0.8, valence: 0.85, acoustic: 0.1, moods: ["happy", "uplifting"] },
|
|
40
|
+
"post-hardcore": { energy: 0.85, valence: 0.45, acoustic: 0.05, moods: ["aggressive", "energetic"] },
|
|
41
|
+
"post-punk": { energy: 0.7, valence: 0.4, acoustic: 0.1, moods: ["dark", "energetic"] },
|
|
42
|
+
screamo: { energy: 0.9, valence: 0.3, acoustic: 0.03, moods: ["aggressive", "dark"] },
|
|
43
|
+
grindcore: { energy: 0.98, valence: 0.3, acoustic: 0.02, moods: ["aggressive", "scary"] },
|
|
44
|
+
grunge: { energy: 0.75, valence: 0.4, acoustic: 0.15, moods: ["aggressive", "dark"] },
|
|
45
|
+
emo: { energy: 0.7, valence: 0.35, acoustic: 0.2, moods: ["sad", "energetic"] },
|
|
46
|
+
stoner: { energy: 0.7, valence: 0.5, acoustic: 0.15, moods: ["dark", "energetic"] },
|
|
47
|
+
sludge: { energy: 0.65, valence: 0.25, acoustic: 0.1, moods: ["dark", "aggressive"] },
|
|
48
|
+
industrial: { energy: 0.8, valence: 0.35, acoustic: 0.02, moods: ["dark", "aggressive"] },
|
|
49
|
+
noise: { energy: 0.7, valence: 0.25, acoustic: 0.05, moods: ["scary", "aggressive"] },
|
|
50
|
+
trance: { energy: 0.85, valence: 0.65, acoustic: 0.02, moods: ["energetic", "uplifting"] },
|
|
51
|
+
dubstep: { energy: 0.85, valence: 0.5, acoustic: 0.02, moods: ["energetic", "dark"] },
|
|
52
|
+
edm: { energy: 0.85, valence: 0.7, acoustic: 0.02, moods: ["energetic", "uplifting"] },
|
|
53
|
+
trap: { energy: 0.75, valence: 0.5, acoustic: 0.05, moods: ["energetic", "dark"] },
|
|
54
|
+
drill: { energy: 0.75, valence: 0.4, acoustic: 0.05, moods: ["dark", "aggressive"] },
|
|
55
|
+
grime: { energy: 0.8, valence: 0.5, acoustic: 0.05, moods: ["aggressive", "energetic"] },
|
|
56
|
+
"k-pop": { energy: 0.8, valence: 0.8, acoustic: 0.08, moods: ["happy", "energetic"] },
|
|
57
|
+
"j-pop": { energy: 0.78, valence: 0.8, acoustic: 0.1, moods: ["happy", "uplifting"] },
|
|
58
|
+
"new wave": { energy: 0.7, valence: 0.65, acoustic: 0.08, moods: ["energetic", "happy"] },
|
|
59
|
+
synthpop: { energy: 0.7, valence: 0.7, acoustic: 0.05, moods: ["happy", "energetic"] },
|
|
60
|
+
"synth-pop": { energy: 0.7, valence: 0.7, acoustic: 0.05, moods: ["happy", "energetic"] },
|
|
61
|
+
reggaeton: { energy: 0.8, valence: 0.75, acoustic: 0.05, moods: ["sexy", "energetic"] },
|
|
62
|
+
latin: { energy: 0.75, valence: 0.75, acoustic: 0.3, moods: ["happy", "energetic"] },
|
|
63
|
+
salsa: { energy: 0.8, valence: 0.8, acoustic: 0.4, moods: ["happy", "energetic"] },
|
|
64
|
+
cumbia: { energy: 0.65, valence: 0.75, acoustic: 0.4, moods: ["happy"] },
|
|
65
|
+
afrobeat: { energy: 0.75, valence: 0.75, acoustic: 0.3, moods: ["happy", "energetic"] },
|
|
66
|
+
ska: { energy: 0.8, valence: 0.75, acoustic: 0.15, moods: ["happy", "energetic"] },
|
|
67
|
+
surf: { energy: 0.7, valence: 0.7, acoustic: 0.2, moods: ["happy", "energetic"] },
|
|
68
|
+
"math rock": { energy: 0.75, valence: 0.55, acoustic: 0.2, moods: ["energetic"] },
|
|
40
69
|
// ── mid / groovy → ship-or-think depending on energy ─────────────────
|
|
41
70
|
"r&b": { energy: 0.55, valence: 0.6, acoustic: 0.2, moods: ["sexy", "chilled"] },
|
|
42
71
|
soul: { energy: 0.55, valence: 0.65, acoustic: 0.25, moods: ["romantic", "uplifting"] },
|
|
43
72
|
reggae: { energy: 0.6, valence: 0.75, acoustic: 0.2, moods: ["chilled", "happy"] },
|
|
44
73
|
indie: { energy: 0.6, valence: 0.55, acoustic: 0.3, moods: ["chilled"] },
|
|
74
|
+
alternative: { energy: 0.65, valence: 0.5, acoustic: 0.2, moods: ["energetic"] },
|
|
75
|
+
progressive: { energy: 0.65, valence: 0.5, acoustic: 0.15, moods: ["epic"] },
|
|
76
|
+
psychedelic: { energy: 0.6, valence: 0.55, acoustic: 0.3, moods: ["ethereal"] },
|
|
77
|
+
krautrock: { energy: 0.6, valence: 0.5, acoustic: 0.2, moods: ["ethereal", "energetic"] },
|
|
78
|
+
synthwave: { energy: 0.65, valence: 0.55, acoustic: 0.02, moods: ["energetic", "ethereal"] },
|
|
79
|
+
"city pop": { energy: 0.6, valence: 0.7, acoustic: 0.3, moods: ["happy", "chilled"] },
|
|
80
|
+
garage: { energy: 0.75, valence: 0.6, acoustic: 0.1, moods: ["energetic"] },
|
|
81
|
+
country: { energy: 0.55, valence: 0.6, acoustic: 0.7, moods: ["happy", "romantic"] },
|
|
82
|
+
bluegrass: { energy: 0.65, valence: 0.7, acoustic: 0.9, moods: ["happy", "uplifting"] },
|
|
83
|
+
americana: { energy: 0.45, valence: 0.5, acoustic: 0.8, moods: ["calm", "romantic"] },
|
|
84
|
+
gospel: { energy: 0.6, valence: 0.8, acoustic: 0.6, moods: ["uplifting", "epic"] },
|
|
85
|
+
swing: { energy: 0.65, valence: 0.75, acoustic: 0.6, moods: ["happy"] },
|
|
86
|
+
"big band": { energy: 0.65, valence: 0.7, acoustic: 0.6, moods: ["happy", "epic"] },
|
|
87
|
+
samba: { energy: 0.7, valence: 0.8, acoustic: 0.5, moods: ["happy"] },
|
|
88
|
+
flamenco: { energy: 0.6, valence: 0.5, acoustic: 0.85, moods: ["romantic", "epic"] },
|
|
45
89
|
// ── low energy, organic → think ──────────────────────────────────────
|
|
46
90
|
ambient: { energy: 0.2, valence: 0.5, acoustic: 0.6, moods: ["ethereal", "calm"] },
|
|
47
91
|
"lo-fi": { energy: 0.3, valence: 0.5, acoustic: 0.4, moods: ["chilled", "calm"] },
|
|
@@ -59,23 +103,41 @@ const GENRE_AFFECT = {
|
|
|
59
103
|
"singer-songwriter": { energy: 0.4, valence: 0.5, acoustic: 0.7, moods: ["calm", "sad"] },
|
|
60
104
|
"post-rock": { energy: 0.5, valence: 0.4, acoustic: 0.4, moods: ["epic", "ethereal"] },
|
|
61
105
|
shoegaze: { energy: 0.55, valence: 0.4, acoustic: 0.3, moods: ["ethereal", "dark"] },
|
|
106
|
+
"dream pop": { energy: 0.45, valence: 0.5, acoustic: 0.3, moods: ["ethereal", "chilled"] },
|
|
107
|
+
"bedroom pop": { energy: 0.45, valence: 0.55, acoustic: 0.3, moods: ["chilled"] },
|
|
108
|
+
vaporwave: { energy: 0.35, valence: 0.45, acoustic: 0.1, moods: ["ethereal", "chilled"] },
|
|
109
|
+
idm: { energy: 0.55, valence: 0.45, acoustic: 0.05, moods: ["ethereal"] },
|
|
110
|
+
"bossa nova": { energy: 0.35, valence: 0.6, acoustic: 0.7, moods: ["chilled", "romantic"] },
|
|
111
|
+
"new age": { energy: 0.2, valence: 0.6, acoustic: 0.7, moods: ["calm", "ethereal"] },
|
|
112
|
+
soundtrack: { energy: 0.45, valence: 0.45, acoustic: 0.6, moods: ["epic"] },
|
|
113
|
+
opera: { energy: 0.5, valence: 0.45, acoustic: 0.9, moods: ["epic"] },
|
|
114
|
+
choral: { energy: 0.3, valence: 0.5, acoustic: 0.95, moods: ["ethereal", "epic"] },
|
|
62
115
|
// ── low energy, low valence → debug-leaning ──────────────────────────
|
|
63
116
|
blues: { energy: 0.45, valence: 0.35, acoustic: 0.5, moods: ["sad", "dark"] },
|
|
117
|
+
goth: { energy: 0.55, valence: 0.3, acoustic: 0.2, moods: ["dark"] },
|
|
118
|
+
gothic: { energy: 0.55, valence: 0.3, acoustic: 0.2, moods: ["dark"] },
|
|
119
|
+
darkwave: { energy: 0.6, valence: 0.35, acoustic: 0.1, moods: ["dark", "ethereal"] },
|
|
120
|
+
drone: { energy: 0.15, valence: 0.3, acoustic: 0.4, moods: ["dark", "ethereal"] },
|
|
64
121
|
slowcore: { energy: 0.25, valence: 0.3, acoustic: 0.5, moods: ["sad", "dark"] },
|
|
65
122
|
sad: { energy: 0.3, valence: 0.2, acoustic: 0.5, moods: ["sad"] },
|
|
66
123
|
melancholy: { energy: 0.3, valence: 0.25, acoustic: 0.5, moods: ["sad", "dark"] },
|
|
67
124
|
melancholic: { energy: 0.3, valence: 0.25, acoustic: 0.5, moods: ["sad", "dark"] },
|
|
68
125
|
doom: { energy: 0.5, valence: 0.2, acoustic: 0.2, moods: ["dark", "scary"] },
|
|
69
126
|
};
|
|
127
|
+
// Keys sorted longest-first so the most SPECIFIC row wins per tag: a
|
|
128
|
+
// "post-rock" tag must hit the post-rock row, never fall through to "rock".
|
|
129
|
+
// (Insertion-order matching silently mis-filed every sub-genre whose parent
|
|
130
|
+
// appeared earlier in the table.)
|
|
131
|
+
const GENRE_KEYS = Object.keys(GENRE_AFFECT).sort((a, b) => b.length - a.length);
|
|
70
132
|
/** Match a list of raw tags against the affect table and aggregate. */
|
|
71
133
|
export function tagsToVibe(tags) {
|
|
72
134
|
const hits = [];
|
|
73
135
|
for (const raw of tags) {
|
|
74
136
|
const t = raw.toLowerCase();
|
|
75
|
-
for (const key
|
|
137
|
+
for (const key of GENRE_KEYS) {
|
|
76
138
|
if (t.includes(key)) {
|
|
77
139
|
hits.push(GENRE_AFFECT[key]);
|
|
78
|
-
break; // one row per tag —
|
|
140
|
+
break; // one row per tag — the longest (most specific) match wins
|
|
79
141
|
}
|
|
80
142
|
}
|
|
81
143
|
}
|
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.3",
|
|
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"
|