Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
159 changes: 159 additions & 0 deletions .claude/skills/add-shabbat-mode/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
---
name: add-shabbat-mode
description: "Pause all activity during Shabbat and Yom Tov"
---

# Add Shabbat Mode

Pauses all NanoClaw outbound activity during Shabbat and Yom Tov. Messages received during these times are stored but not processed. After Shabbat ends, the message loop picks up queued messages on its next poll cycle. Optionally sends candle lighting reminders every erev Shabbat and erev Yom Tov.

## Timing Reference

- **Candle lighting**: 18 minutes before shkiya (sunset). This is when Shabbat/Yom Tov begins in practice.
- **Shkiya** (sunset): the halachic start boundary used by this system.
- **Tzeit hakochavim** (nightfall): calculated at 8.5 degrees below horizon.
- **Resume time**: tzeit + configurable buffer (default 18 minutes) on motzaei Shabbat/Yom Tov.

Note: the system activates at shkiya rather than candle lighting because candle lighting is preparation, while the halachic prohibition begins at shkiya. The system pauses 18 minutes *after* the household has already lit candles.

## Phase 1: Pre-flight

### Check if already applied

Read `.nanoclaw/state.yaml`. If `shabbat-mode` is in `applied_skills`, skip to Phase 3 (Generate Schedule). The code changes are already in place.

### Ask the user

1. **Location** — latitude, longitude, and timezone for zmanim calculation. No default — the user must provide their location.
2. **Israel or Diaspora** — determines Yom Tov observance. Israel keeps 1-day Yom Tov, diaspora keeps 2 days.
3. **Candle lighting notifications** — send a reminder to the user every erev Shabbat and erev Yom Tov with the candle lighting time? Default: yes.
4. **Elevation** — meters above sea level. Default: 0m.
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SKILL.md states the default elevation is 0m, but generate-zmanim.ts defaults SHABBAT_ELEVATION to 25. This mismatch can lead to surprising output and makes the setup guide less trustworthy. Consider aligning the script default with the documented default (or updating the doc to match the script).

Suggested change
4. **Elevation** — meters above sea level. Default: 0m.
4. **Elevation** — meters above sea level. Default: 25m.

Copilot uses AI. Check for mistakes.
5. **Tzeit buffer** — extra minutes after tzeit hakochavim before resuming. Default: 18 minutes.

## Phase 2: Apply Code Changes

Run the skills engine to apply this skill's code package.

### Initialize skills system (if needed)

If `.nanoclaw/` directory doesn't exist yet:

```bash
npx tsx scripts/apply-skill.ts --init
```

### Apply the skill

```bash
npx tsx scripts/apply-skill.ts .claude/skills/add-shabbat-mode
```

This deterministically:
- Adds `src/shabbat.ts` (runtime module with `isShabbatOrYomTov()` binary search + candle lighting notifier)
- Adds `src/shabbat.test.ts` (12 test cases for boundary conditions and candle lighting)
- Adds `scripts/generate-zmanim.ts` (standalone schedule generator using `@hebcal/core`)
- Three-way merges Shabbat guards into `src/index.ts` (message loop + processGroupMessages)
- Three-way merges Shabbat guard into `src/task-scheduler.ts` (scheduler loop)
- Three-way merges Shabbat guard into `src/ipc.ts` (IPC watcher)
- Records the application in `.nanoclaw/state.yaml`

If the apply reports merge conflicts, read the intent files:
- `modify/src/index.ts.intent.md` — guard points in message loop and processGroupMessages
- `modify/src/task-scheduler.ts.intent.md` — guard point in scheduler loop
- `modify/src/ipc.ts.intent.md` — guard point in IPC watcher

### Validate code changes

```bash
npx vitest run src/shabbat.test.ts
npm test
npm run build
```

All 12 shabbat tests must pass, full suite must pass, and build must be clean before proceeding.

## Phase 3: Generate Schedule

### Install hebcal (one-time)

`@hebcal/core` is a standalone dependency for the generator script, not a project dependency:

```bash
npm install --no-save @hebcal/core
```

### Generate the schedule

```bash
SHABBAT_LAT=<lat> SHABBAT_LNG=<lng> SHABBAT_TIMEZONE=<tz> SHABBAT_IL=<true|false> npx tsx scripts/generate-zmanim.ts
```

| Variable | Required | Description |
|----------|----------|-------------|
| `SHABBAT_LAT` | Yes | Latitude |
| `SHABBAT_LNG` | Yes | Longitude |
| `SHABBAT_TIMEZONE` | Yes | IANA timezone (e.g. `America/New_York`, `Asia/Jerusalem`) |
| `SHABBAT_IL` | No | `true` for Israel (1-day Yom Tov), default `false` (diaspora) |
| `SHABBAT_LOCATION` | No | Cosmetic label for logs |
| `SHABBAT_ELEVATION` | No | Meters above sea level (default 0) |
| `SHABBAT_BUFFER` | No | Minutes after tzeit before resuming (default 18) |
| `SHABBAT_YEARS` | No | Years of schedule to generate (default 5) |

Expected output: `data/shabbat-schedule.json` with 300+ windows covering 5 years.

### Sanity-check

Verify the output:
- Has 300+ windows
- First upcoming Friday window starts at correct shkiya time for the location
- Yom Tov events are present (Rosh Hashana, Pesach, Sukkot, Shavuot, Yom Kippur)
- Multi-day Yom Tov merged into single windows
- Adjacent Shabbat+Yom Tov merged

## Phase 4: Build and Restart

```bash
npm run build
```

Linux:
```bash
systemctl --user restart nanoclaw
```

macOS:
```bash
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
```

## Phase 5: Verify

### Check logs

```bash
grep -i shabbat logs/nanoclaw.log | tail -5
```

Look for:
- `Shabbat schedule loaded` with window count — successful initialization
- `Shabbat schedule expires soon!` — schedule nearing expiration, regenerate

### Test behavior

During Shabbat: messages arrive in DB but no agent containers spawn, no outbound messages sent, no scheduled tasks execute, no IPC processed.

After Shabbat: message loop picks up queued messages, scheduler fires due tasks, IPC watcher processes pending files.

## Troubleshooting

### "No Shabbat schedule found, Shabbat mode disabled"

The schedule file doesn't exist. Generate it (see Phase 3), then restart the service.

### "Shabbat schedule expires soon!"

Regenerate the schedule (see Phase 3), then restart. The new schedule covers 5 years from the current date.

### Wrong times for location

Regenerate with correct coordinates (see Phase 3).
133 changes: 133 additions & 0 deletions .claude/skills/add-shabbat-mode/add/scripts/generate-zmanim.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/**
* Generate Shabbat and Yom Tov schedule for NanoClaw.
*
* Uses @hebcal/core to compute shkiya and tzeis times.
* Outputs a flat JSON file of restricted windows to data/shabbat-schedule.json.
*
* Run: npm run generate-zmanim
*/
import fs from 'fs';
import path from 'path';
import { GeoLocation, Zmanim, HebrewCalendar, flags } from '@hebcal/core';

// Defaults — override via CLI args or edit before running
const LAT = parseFloat(process.env.SHABBAT_LAT || '40.669');
const LNG = parseFloat(process.env.SHABBAT_LNG || '-73.943');
const ELEVATION = parseFloat(process.env.SHABBAT_ELEVATION || '25');
const LOCATION_NAME = process.env.SHABBAT_LOCATION || 'Crown Heights, Brooklyn';
const TIMEZONE = process.env.SHABBAT_TIMEZONE || 'America/New_York';
const YEARS_TO_GENERATE = parseInt(process.env.SHABBAT_YEARS || '5', 10);
const TZEIS_BUFFER_MINUTES = parseInt(process.env.SHABBAT_BUFFER || '18', 10);
const ISRAEL = process.env.SHABBAT_IL === 'true';
const OUTPUT_PATH = path.resolve(process.cwd(), 'data', 'shabbat-schedule.json');

interface ShabbatWindow {
start: string;
end: string;
type: 'shabbat' | 'yomtov' | 'shabbat+yomtov';
label: string;
}

const geo = new GeoLocation(LOCATION_NAME, LAT, LNG, ELEVATION, TIMEZONE);

function getShkiya(date: Date): Date {
const zmanim = new Zmanim(geo, date);
return zmanim.sunset();
}

function getTzeisWithBuffer(date: Date): Date {
const zmanim = new Zmanim(geo, date);
const tzeis = zmanim.tzeit(8.5);
return new Date(tzeis.getTime() + TZEIS_BUFFER_MINUTES * 60 * 1000);
}

function generateWindows(startYear: number, endYear: number): ShabbatWindow[] {
const rawWindows: ShabbatWindow[] = [];

// Shabbat windows: Friday sunset → Saturday night
const startDate = new Date(startYear, 0, 1);
const endDate = new Date(endYear + 1, 0, 1);

for (let d = new Date(startDate); d < endDate; d.setDate(d.getDate() + 1)) {
if (d.getDay() === 5) {
const friday = new Date(d);
const saturday = new Date(d);
saturday.setDate(saturday.getDate() + 1);
rawWindows.push({
start: getShkiya(friday).toISOString(),
end: getTzeisWithBuffer(saturday).toISOString(),
type: 'shabbat',
label: 'Shabbat',
});
}
}

// Yom Tov windows
for (let year = startYear; year <= endYear; year++) {
const events = HebrewCalendar.calendar({
year,
isHebrewYear: false,
il: ISRAEL,
mask: flags.CHAG,
});
Comment on lines +66 to +72
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The generator hard-codes il: false, so it always generates diaspora Yom Tov dates. This conflicts with the skill docs/PR description that mention an Israel vs diaspora preference (SHABBAT_IL) and will produce wrong schedules for Israel users. Consider reading an env var/CLI flag (e.g. SHABBAT_IL) and wiring it into the HebrewCalendar.calendar({ il: ... }) call.

Copilot uses AI. Check for mistakes.

for (const ev of events) {
const gregDate = ev.getDate().greg();
const erev = new Date(gregDate);
erev.setDate(erev.getDate() - 1);
rawWindows.push({
start: getShkiya(erev).toISOString(),
end: getTzeisWithBuffer(gregDate).toISOString(),
type: 'yomtov',
label: ev.getDesc(),
});
}
}

// Sort and merge overlapping windows
rawWindows.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime());

const merged: ShabbatWindow[] = [];
for (const w of rawWindows) {
const last = merged[merged.length - 1];
if (last && new Date(w.start).getTime() <= new Date(last.end).getTime()) {
if (new Date(w.end).getTime() > new Date(last.end).getTime()) {
last.end = w.end;
}
if (last.type !== w.type) last.type = 'shabbat+yomtov';
last.label = `${last.label} / ${w.label}`;
} else {
merged.push({ ...w });
}
}

return merged;
}

const now = new Date();
const startYear = now.getFullYear();
const endYear = startYear + YEARS_TO_GENERATE - 1;

console.log(`Generating Shabbat/Yom Tov schedule for ${startYear}-${endYear}...`);
console.log(`Location: ${LOCATION_NAME} (${LAT}, ${LNG})`);

const windows = generateWindows(startYear, endYear);

const schedule = {
location: LOCATION_NAME,
coordinates: [LAT, LNG],
elevation: ELEVATION,
timezone: TIMEZONE,
tzeisBufferMinutes: TZEIS_BUFFER_MINUTES,
generatedAt: now.toISOString(),
expiresAt: new Date(endYear + 1, 0, 1).toISOString(),
windowCount: windows.length,
windows,
};

fs.mkdirSync(path.dirname(OUTPUT_PATH), { recursive: true });
fs.writeFileSync(OUTPUT_PATH, JSON.stringify(schedule, null, 2));

console.log(`Generated ${windows.length} windows`);
console.log(`Written to ${OUTPUT_PATH}`);
console.log(`Schedule valid until ${schedule.expiresAt}`);
Loading
Loading