Skill: wangjian124/period-care-assistant (version 0.1.0)

--- name: period-care-assistant description: Track menstrual cycle history, answer current cycle status questions, record new period start dates from natural-language messages such as "月经来了", predict the next expected start date, and prepare reminder schedules for OpenClaw users who need private menstrual health tracking with local encrypted storage. metadata: {"openclaw":{"emoji":"🩺","homepage":"https://docs.openclaw.ai/tools/clawhub","os":["darwin","linux","win32"],"requires":{"anyBins":["node","nodejs"],"env":["PERIOD_TRACKER_KEY"]},"primaryEnv":"PERIOD_TRACKER_KEY"}} --- # Period Care Assistant ## Overview Use the helper script at `{baseDir}/scripts/period_tracker.mjs` for all reads and writes so period history, forecasts, and reminder plans stay deterministic. Keep all cycle data local, encrypted, and scoped by a stable per-user key such as `dingtalk:<staffId>` or `telegram:<userId>`. ## Handle User Identity Build a stable `userKey` before touching storage. - Prefer the channel's immutable sender identifier. - Prefix it with the transport, for example `telegram:123456`, `slack:U123`, or `dingtalk:manager123`. - Do not use display names as the primary key unless there is no stable id. - If the user is in a shared chat, keep records per person and confirm who the record belongs to before writing. ## Record A New Period Start When the user says anything equivalent to "月经来了", "今天来例假了", "帮我记一下今天来姨妈", or "记一下 2026-03-18 来月经": 1. Infer the start date from the message. If no date is given, use the user's local date and say which date was recorded. 2. Run: ```bash node "{baseDir}/scripts/period_tracker.mjs" record --user "<userKey>" --date "<YYYY-MM-DD>" --json ``` 3. Read the returned JSON and reply with: - the recorded start date, - the predicted next start date, - the reminder date, - a short caring sentence. 4. If the user also gives reminder preference, timezone, route, or period length, include: ```bash node "{baseDir}/scripts/period_tracker.mjs" record --user "<userKey>" --date "<YYYY-MM-DD>" --reminder-days 4 --timezone "Asia/Shanghai" --delivery-mode webhook --delivery-webhook "https://example.invalid/reminder" --json ``` ## Answer Status Queries When the user asks "我现在是什么周期", "帮我查一下月经周期", "下次大概什么时候来", or similar: ```bash node "{baseDir}/scripts/period_tracker.mjs" status --user "<userKey>" --json ``` Explain the result in natural language: - current phase, - days since last recorded start, - predicted next start date, - reminder status, - confidence level and uncertainty when history is sparse or irregular. Do not state medical certainty. Say the estimate is data-based, not a diagnosis. ## Configure Reminder Delivery The helper script stores reminder preferences and generates a one-shot cron plan. Use: ```bash node "{baseDir}/scripts/period_tracker.mjs" configure --user "<userKey>" --timezone "Asia/Shanghai" --reminder-days 4 --delivery-mode announce --delivery-channel "telegram" --delivery-to "user:123456" --json ``` Supported delivery modes in the helper: - `announce`: for native OpenClaw chat delivery routes. - `webhook`: for an external bridge or webhook endpoint. - `none`: keep the prediction but do not arm a delivery route yet. If reminder delivery is configured, fetch the plan: ```bash node "{baseDir}/scripts/period_tracker.mjs" reminder-plan --user "<userKey>" --json ``` Then create or refresh a one-shot cron job. Prefer a deterministic job name from the JSON output. Use `schedule.kind = "at"` and the generated ISO timestamp. For isolated delivery jobs, use the generated prompt text as the `agentTurn.message`. See `{baseDir}/references/deployment.md` for a complete cron JSON example. ## Natural-Language Interaction Style Keep the interaction short, warm, and explicit. - Accept colloquial Chinese such as "姨妈来了", "例假来了", "下次大概啥时候". - After recording, confirm the exact date that was saved. - If fewer than two historical records exist, explain that the next prediction is a provisional baseline. - If the forecast confidence is low or the estimated error exceeds two days, say that clearly instead of overstating accuracy. - If the user says the cycle is highly irregular, severe pain is present, or bleeding is unusual, suggest professional medical advice. ## Privacy And Safety Rules - Never print or quote the encryption key. - Keep raw history in the encrypted store only. - Avoid putting full menstrual history into logs, commit messages, or public summaries. - Share only the minimum needed in chat responses unless the user explicitly asks for the detailed history. - If this skill is published to ClawHub, remember ClawHub is public. Publish code and instructions only, never real user data or secrets. ## Read More Only When Needed - Read `{baseDir}/references/deployment.md` when you need OpenClaw cron, ClawHub publish, or DingTalk transport notes. - Read `{baseDir}/references/model-and-privacy.md` when you need the forecasting method, storage layout, or accuracy caveats.

path: agents/openai.yaml
content: interface:
  display_name: "Period Care Assistant"
  short_description: "Track menstrual cycles and reminder workflows"
  default_prompt: "Use $period-care-assistant to log a new period event, predict the next start date, and prepare a reminder."


path: references/deployment.md
content: # Deployment Notes

## Environment

Set `PERIOD_TRACKER_KEY` before the skill runs. OpenClaw can inject it from `skills.entries."period-care-assistant".env` or `apiKey`.

Example `~/.openclaw/openclaw.json` snippet:

```json5
{
  skills: {
    entries: {
      "period-care-assistant": {
        enabled: true,
        [REDACTED_SECRET]",
        env: {
          PERIOD_TRACKER_KEY: "replace-with-a-long-random-secret",
          PERIOD_TRACKER_STORE: "~/.openclaw/workspace/skills/period-care-assistant/.state/period-tracker.enc",
        },
      },
    },
  },
}
```

`PERIOD_TRACKER_STORE` is optional. If omitted, the script writes to `{baseDir}/.state/period-tracker.enc`.

## Cron Plan

OpenClaw cron supports one-shot jobs with `schedule.kind = "at"` and an ISO timestamp. The helper script returns a ready-to-use UTC timestamp plus a delivery block.

Example workflow:

```bash
node "{baseDir}/scripts/period_tracker.mjs" reminder-plan --user "telegram:123456" --json
```

Then create a cron job with JSON shaped like this:

```json
{
  "name": "period-reminder-3f2f188a94ad",
  "schedule": { "kind": "at", "at": "2026-03-25T01:00:00.000Z" },
  "sessionTarget": "isolated",
  "wakeMode": "now",
  "payload": {
    "kind": "agentTurn",
    "message": "Use $period-care-assistant to send a short caring reminder to user telegram:123456. Predicted next period start date: 2026-03-29. Reminder text: 温馨提醒：按你最近的记录，预计下次月经会在 2026-03-29 左右开始，可以提前准备卫生用品并安排好休息。如果这次明显提前或推迟，直接告诉我，我会重新更新预测。"
  },
  "delivery": {
    "mode": "announce",
    "channel": "telegram",
    "to": "user:123456"
  },
  "deleteAfterRun": true
}
```

Notes from the OpenClaw cron docs:

- One-shot reminders should use `schedule.kind = "at"`.
- If the ISO timestamp omits a timezone, OpenClaw treats it as UTC.
- Cron only runs while the Gateway process is running.

## DingTalk Reality Check

The official OpenClaw channel and outbound message docs currently list WhatsApp, Telegram, Discord, Slack, Google Chat, Signal, iMessage, MS Teams, plus plugin-based channels such as Feishu and Mattermost. DingTalk is not listed as a native channel at the time of writing.

That means this skill can deliver the business logic now, but true DingTalk messaging needs one of these extra pieces:

1. A custom DingTalk channel or plugin for OpenClaw.
2. A webhook bridge that converts OpenClaw webhook or cron delivery events into DingTalk bot messages.
3. A relay service that receives DingTalk inbound messages and forwards them to OpenClaw `POST /hooks/agent`.

For external triggers, OpenClaw can expose `POST /hooks/agent` when hooks are enabled. That is the cleanest official entry point for a DingTalk bridge.

## Publish To ClawHub

ClawHub is public. Publish only code, docs, and sample config.

Typical commands:

```bash
npm i -g clawhub
clawhub login
clawhub publish ./period-care-assistant --slug period-care-assistant --name "Period Care Assistant" --version 0.1.0 --tags latest --changelog "Initial release"
```

Useful follow-ups:

```bash
clawhub whoami
clawhub sync --all
```

## Local Validation

Run the helper tests:

```bash
node "{baseDir}/scripts/period_tracker.test.mjs"
```

Run a manual smoke test:

```bash
node "{baseDir}/scripts/period_tracker.mjs" record --user "demo:alice" --date "2026-03-01" --json
node "{baseDir}/scripts/period_tracker.mjs" record --user "demo:alice" --date "2026-03-29" --json
node "{baseDir}/scripts/period_tracker.mjs" status --user "demo:alice" --today "2026-04-20" --json
```


path: references/model-and-privacy.md
content: # Forecast Model And Privacy

## Storage Model

The helper script stores one encrypted JSON document containing:

- `version`
- `users.<userKey>.settings`
- `users.<userKey>.records[]`
- `users.<userKey>.reminder`

Encryption uses AES-256-GCM with a per-save random salt and IV. The data key is derived from `PERIOD_TRACKER_KEY` with `scrypt`.

## Forecast Method

The script predicts the next start date from recorded period start dates only. It does not infer end dates unless the user configures an estimated period length.

For cycle intervals, the script:

1. sorts unique start dates,
2. computes interval lengths between consecutive starts,
3. looks at the most recent six intervals,
4. blends median, weighted recent mean, and arithmetic mean,
5. clamps the result to a sensible 18-45 day range,
6. estimates confidence from recent variability and rolling historical error.

Why this model:

- median resists outliers,
- weighted mean adapts to recent trend changes,
- average smooths noise.

## Confidence

The script reports:

- `high`: enough samples and recent rolling mean absolute error is within roughly two days,
- `medium`: usable data but more variance,
- `low`: sparse or irregular history.

If the confidence is not high, avoid claiming the forecast satisfies a strict `±2` day target.

## Current Phase

The phase label is heuristic, based on:

- last recorded start date,
- estimated period length,
- expected ovulation window around `predictedCycleLength - 14`,
- reminder window before the next expected start.

These labels are for convenience only and should not be presented as medical advice.

## Delivery Reliability Caveat

No messaging stack can honestly guarantee absolute `100%` delivery across external providers. The best practical design is:

1. deterministic cron scheduling,
2. a stable per-user delivery route,
3. failure logging,
4. retry or escalation outside the skill if the provider reports failure.

This skill prepares the deterministic part. Transport guarantees depend on the configured OpenClaw channel or external DingTalk bridge.


path: scripts/period_tracker.mjs
content: #!/usr/bin/env node

import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const BASE_DIR = path.resolve(__dirname, "..");
const DEFAULT_STORE_PATH = process.env.PERIOD_TRACKER_STORE
  ? expandHome(process.env.PERIOD_TRACKER_STORE)
  : path.join(BASE_DIR, ".state", "period-tracker.enc");
const DEFAULTS = Object.freeze({
  reminderDaysBefore: 4,
  periodLengthDays: 5,
  timezone: "Asia/Shanghai",
  reminderTime: "09:00",
  deliveryMode: "none",
  deliveryChannel: null,
  deliveryTo: null,
  deliveryWebhook: null,
});

function expandHome(inputPath) {
  if (!inputPath || !inputPath.startsWith("~")) {
    return inputPath;
  }

  const home = process.env.HOME || process.env.USERPROFILE;
  if (!home) {
    return inputPath;
  }
  return path.join(home, inputPath.slice(1));
}

function parseArgs(argv) {
  const args = { _: [] };
  for (let index = 0; index < argv.length; index += 1) {
    const [REDACTED_SECRET];
    if (!token.startsWith("--")) {
      args._.push(token);
      continue;
    }
    const key = token.slice(2);
    const next = argv[index + 1];
    if (!next || next.startsWith("--")) {
      args[key] = true;
      continue;
    }
    args[key] = next;
    index += 1;
  }
  return args;
}

function requiredSecret() {
  const [REDACTED_SECRET];
  if (!secret) {
    throw new Error("PERIOD_TRACKER_KEY is required.");
  }
  return secret;
}

function emptyStore() {
  return { version: 1, users: {} };
}

function loadStore(storePath = DEFAULT_STORE_PATH, [REDACTED_SECRET])) {
  if (!fs.existsSync(storePath)) {
    return emptyStore();
  }

  const envelope = JSON.parse(fs.readFileSync(storePath, "utf8"));
  if (envelope.version !== 1) {
    throw new Error(`Unsupported store version: ${String(envelope.version)}`);
  }

  const salt = Buffer.from(envelope.salt, "base64");
  const iv = Buffer.from(envelope.iv, "base64");
  const tag = Buffer.from(envelope.tag, "base64");
  const ciphertext = Buffer.from(envelope.data, "base64");
  const key = crypto.scryptSync(secret, salt, 32);
  const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv);
  decipher.setAuthTag(tag);
  const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
  return JSON.parse(plaintext.toString("utf8"));
}

function saveStore(store, storePath = DEFAULT_STORE_PATH, [REDACTED_SECRET])) {
  fs.mkdirSync(path.dirname(storePath), { recursive: true });
  const salt = crypto.randomBytes(16);
  const iv = crypto.randomBytes(12);
  const key = crypto.scryptSync(secret, salt, 32);
  const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
  const plaintext = Buffer.from(JSON.stringify(store, null, 2), "utf8");
  const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
  const tag = cipher.getAuthTag();
  const envelope = {
    version: 1,
    algorithm: "aes-256-gcm",
    kdf: "scrypt",
    salt: salt.toString("base64"),
    iv: iv.toString("base64"),
    tag: tag.toString("base64"),
    data: ciphertext.toString("base64"),
  };
  fs.writeFileSync(storePath, `${JSON.stringify(envelope, null, 2)}\n`, "utf8");
}

function ensureUser(store, userKey) {
  if (!store.users[userKey]) {
    store.users[userKey] = {
      settings: { ...DEFAULTS },
      records: [],
      reminder: null,
    };
  }

  const user = store.users[userKey];
  user.settings = { ...DEFAULTS, ...user.settings };
  user.records = Array.isArray(user.records) ? user.records : [];
  return user;
}

function parseDateString(input) {
  if (!/^\d{4}-\d{2}-\d{2}$/.test(input)) {
    throw new Error(`Invalid date '${input}'. Expected YYYY-MM-DD.`);
  }

  const [year, month, day] = input.split("-").map(Number);
  const date = new Date(Date.UTC(year, month - 1, day));
  if (
    Number.isNaN(date.getTime()) ||
    date.getUTCFullYear() !== year ||
    date.getUTCMonth() !== month - 1 ||
    date.getUTCDate() !== day
  ) {
    throw new Error(`Invalid calendar date '${input}'.`);
  }
  return date;
}

function formatDate(date) {
  const year = date.getUTCFullYear();
  const month = String(date.getUTCMonth() + 1).padStart(2, "0");
  const day = String(date.getUTCDate()).padStart(2, "0");
  return `${year}-${month}-${day}`;
}

function addDays(input, days) {
  const date = typeof input === "string" ? parseDateString(input) : new Date(input.getTime());
  date.setUTCDate(date.getUTCDate() + days);
  return formatDate(date);
}

function diffDays(left, right) {
  const leftDate = typeof left === "string" ? parseDateString(left) : left;
  const rightDate = typeof right === "string" ? parseDateString(right) : right;
  return Math.round((leftDate.getTime() - rightDate.getTime()) / 86400000);
}

function currentDateInTimeZone(timezone) {
  const parts = new Intl.DateTimeFormat("en-CA", {
    timeZone: timezone,
    year: "numeric",
    month: "2-digit",
    day: "2-digit",
  }).formatToParts(new Date());
  const values = Object.fromEntries(
    parts
      .filter((part) => part.type !== "literal")
      .map((part) => [part.type, part.value]),
  );
  return `${values.year}-${values.month}-${values.day}`;
}

function clamp(value, min, max) {
  return Math.min(Math.max(value, min), max);
}

function average(values) {
  if (!values.length) {
    return null;
  }
  return values.reduce((sum, value) => sum + value, 0) / values.length;
}

function median(values) {
  if (!values.length) {
    return null;
  }
  const sorted = [...values].sort((left, right) => left - right);
  const middle = Math.floor(sorted.length / 2);
  if (sorted.length % 2 === 1) {
    return sorted[middle];
  }
  return (sorted[middle - 1] + sorted[middle]) / 2;
}

function weightedAverage(values) {
  if (!values.length) {
    return null;
  }
  let weightedSum = 0;
  let weightTotal = 0;
  values.forEach((value, index) => {
    const weight = index + 1;
    weightedSum += value * weight;
    weightTotal += weight;
  });
  return weightedSum / weightTotal;
}

function stddev(values) {
  if (values.length < 2) {
    return 0;
  }
  const mean = average(values);
  const variance = average(values.map((value) => (value - mean) ** 2));
  return Math.sqrt(variance);
}

function round(value) {
  if (value == null || Number.isNaN(value)) {
    return null;
  }
  return Math.round(value * 10) / 10;
}

function sanitizeRecords(records) {
  const seen = new Set();
  return records
    .map((record) => ({
      startDate: String(record.startDate),
      source: record.source || "user",
      recordedAt: record.recordedAt || new Date().toISOString(),
    }))
    .filter((record) => {
      if (!/^\d{4}-\d{2}-\d{2}$/.test(record.startDate)) {
        return false;
      }
      if (seen.has(record.startDate)) {
        return false;
      }
      seen.add(record.startDate);
      return true;
    })
    .sort((left, right) => left.startDate.localeCompare(right.startDate));
}

function summarizeIntervals(intervals) {
  const recent = intervals.slice(-6);
  if (!recent.length) {
    return {
      recent,
      medianCycle: 28,
      averageCycle: 28,
      weightedCycle: 28,
      variability: 0,
      predictedCycleLength: 28,
    };
  }

  const medianCycle = median(recent);
  const averageCycle = average(recent);
  const weightedCycle = weightedAverage(recent);
  const variability = stddev(recent);
  const predictedCycleLength = clamp(
    Math.round(medianCycle * 0.5 + weightedCycle * 0.35 + averageCycle * 0.15),
    18,
    45,
  );

  return {
    recent,
    medianCycle,
    averageCycle,
    weightedCycle,
    variability,
    predictedCycleLength,
  };
}

function rollingMae(startDates) {
  if (startDates.length < 4) {
    return null;
  }

  const errors = [];
  for (let index = 2; index < startDates.length; index += 1) {
    const history = startDates.slice(0, index);
    const intervals = [];
    for (let offset = 1; offset < history.length; offset += 1) {
      intervals.push(diffDays(history[offset], history[offset - 1]));
    }
    const summary = summarizeIntervals(intervals);
    const predictedDate = addDays(history[history.length - 1], summary.predictedCycleLength);
    errors.push(Math.abs(diffDays(startDates[index], predictedDate)));
  }
  return average(errors);
}

function evaluateConfidence(sampleSize, variability, mae) {
  if (sampleSize >= 5 && variability <= 2.5 && mae != null && mae <= 2) {
    return "high";
  }
  if (sampleSize >= 2 && variability <= 4.5 && (mae == null || mae <= 4)) {
    return "medium";
  }
  return "low";
}

function makeReminderJobName(userKey) {
  return `period-reminder-${crypto.createHash("sha256").update(userKey).digest("hex").slice(0, 12)}`;
}

function localMinuteIndex(dateString, timeString) {
  const [hours, minutes] = timeString.split(":").map(Number);
  return Math.round(parseDateString(dateString).getTime() / 60000) + hours * 60 + minutes;
}

function zoneParts(date, timezone) {
  const parts = new Intl.DateTimeFormat("en-CA", {
    timeZone: timezone,
    year: "numeric",
    month: "2-digit",
    day: "2-digit",
    hour: "2-digit",
    minute: "2-digit",
    hourCycle: "h23",
  }).formatToParts(date);
  const values = Object.fromEntries(
    parts
      .filter((part) => part.type !== "literal")
      .map((part) => [part.type, part.value]),
  );
  return {
    date: `${values.year}-${values.month}-${values.day}`,
    time: `${values.hour}:${values.minute}`,
  };
}

function localDateTimeToUtcIso(dateString, timeString, timezone) {
  let guess = Date.parse(`${dateString}T${timeString}:00Z`);
  const target = localMinuteIndex(dateString, timeString);
  for (let attempts = 0; attempts < 5; attempts += 1) {
    const current = zoneParts(new Date(guess), timezone);
    const delta = target - localMinuteIndex(current.date, current.time);
    if (delta === 0) {
      return new Date(guess).toISOString();
    }
    guess += delta * 60000;
  }
  return new Date(guess).toISOString();
}

function composeReminderText(predictedNextStart) {
  return `温馨提醒：按你最近的记录，预计下次月经会在 ${predictedNextStart} 左右开始，可以提前准备卫生用品并安排好休息。如果这次明显提前或推迟，直接告诉我，我会重新更新预测。`;
}

function buildReminder(userKey, settings, predictedNextStart) {
  if (!predictedNextStart) {
    return null;
  }
  const reminderDate = addDays(predictedNextStart, -settings.reminderDaysBefore);
  const reminderTime = settings.reminderTime || DEFAULTS.reminderTime;
  return {
    jobName: makeReminderJobName(userKey),
    predictedNextStart,
    reminderDate,
    reminderTime,
    timezone: settings.timezone,
    scheduleAt: localDateTimeToUtcIso(reminderDate, reminderTime, settings.timezone),
    delivery: {
      mode: settings.deliveryMode,
      channel: settings.deliveryChannel,
      to: settings.deliveryTo,
      webhook: settings.deliveryWebhook,
    },
    reminderText: composeReminderText(predictedNextStart),
  };
}

function computeUserStats(userKey, user) {
  const records = sanitizeRecords(user.records);
  user.records = records;

  if (!records.length) {
    user.reminder = null;
    return {
      sampleSize: 0,
      lastStartDate: null,
      predictedCycleLength: 28,
      predictedNextStart: null,
      averageCycleLength: null,
      medianCycleLength: null,
      weightedCycleLength: null,
      variability: null,
      historicalMae: null,
      confidence: "low",
      reminder: null,
    };
  }

  const starts = records.map((record) => record.startDate);
  const intervals = [];
  for (let index = 1; index < starts.length; index += 1) {
    intervals.push(diffDays(starts[index], starts[index - 1]));
  }
  const summary = summarizeIntervals(intervals);
  const historicalMae = rollingMae(starts);
  const confidence = evaluateConfidence(summary.recent.length, summary.variability, historicalMae);
  const predictedNextStart = addDays(starts[starts.length - 1], summary.predictedCycleLength);
  const reminder = buildReminder(userKey, user.settings, predictedNextStart);
  user.reminder = reminder;

  return {
    sampleSize: summary.recent.length,
    lastStartDate: starts[starts.length - 1],
    predictedCycleLength: summary.predictedCycleLength,
    predictedNextStart,
    averageCycleLength: round(summary.averageCycle),
    medianCycleLength: round(summary.medianCycle),
    weightedCycleLength: round(summary.weightedCycle),
    variability: round(summary.variability),
    historicalMae: round(historicalMae),
    confidence,
    reminder,
  };
}

function resolvePhase(daysSinceLast, daysUntilNext, predictedCycleLength, periodLengthDays, reminderDaysBefore) {
  if (daysSinceLast < 0) {
    return { code: "unknown", label: "日期异常", description: "当前日期早于最近一次记录。" };
  }
  if (daysSinceLast < periodLengthDays) {
    return { code: "menstrual", label: "经期中", description: "仍处于本次经期的估计窗口内。" };
  }
  const ovulationDay = Math.max(0, predictedCycleLength - 14);
  if (Math.abs(daysSinceLast - ovulationDay) <= 1) {
    return { code: "ovulation", label: "排卵窗口", description: "接近基于历史数据估计的排卵窗口。" };
  }
  if (daysSinceLast < ovulationDay - 1) {
    return { code: "follicular", label: "卵泡期", description: "处于经期后到排卵前的估计阶段。" };
  }
  if (daysUntilNext >= 0 && daysUntilNext <= reminderDaysBefore) {
    return { code: "premenstrual", label: "经前提醒期", description: "已进入下次月经前的提醒窗口。" };
  }
  if (daysUntilNext < 0) {
    return { code: "late", label: "可能推迟", description: "已经超过预测开始日，建议用新记录尽快修正模型。" };
  }
  return { code: "luteal", label: "黄体期", description: "处于排卵后到下次月经前的估计阶段。" };
}

function buildStatus(userKey, user, today) {
  const stats = computeUserStats(userKey, user);
  if (!stats.lastStartDate) {
    return {
      userKey,
      hasData: false,
      today,
      message: "还没有可用的月经起始记录。至少记录一次“月经来了”后才能开始预测。",
      settings: user.settings,
      stats,
    };
  }

  const daysSinceLast = diffDays(today, stats.lastStartDate);
  const daysUntilNext = diffDays(stats.predictedNextStart, today);
  const phase = resolvePhase(
    daysSinceLast,
    daysUntilNext,
    stats.predictedCycleLength,
    user.settings.periodLengthDays,
    user.settings.reminderDaysBefore,
  );

  return {
    userKey,
    hasData: true,
    today,
    settings: user.settings,
    currentPhase: phase,
    daysSinceLastStart: daysSinceLast,
    daysUntilNextStart: daysUntilNext,
    predictedNextStart: stats.predictedNextStart,
    cycleLengthDays: stats.predictedCycleLength,
    periodLengthDays: user.settings.periodLengthDays,
    confidence: stats.confidence,
    historicalMaeDays: stats.historicalMae,
    variabilityDays: stats.variability,
    lastStartDate: stats.lastStartDate,
    reminder: stats.reminder,
  };
}

function maybeNumber(value, flagName) {
  if (value == null) {
    return null;
  }
  const number = Number(value);
  if (!Number.isFinite(number)) {
    throw new Error(`Invalid number for --${flagName}: ${String(value)}`);
  }
  return number;
}

function applySettingOverrides(user, args) {
  if (args.timezone) {
    currentDateInTimeZone(args.timezone);
    user.settings.timezone = args.timezone;
  }
  if (args["reminder-days"]) {
    user.settings.reminderDaysBefore = clamp(
      Math.round(maybeNumber(args["reminder-days"], "reminder-days")),
      1,
      10,
    );
  }
  if (args["period-length"]) {
    user.settings.periodLengthDays = clamp(
      Math.round(maybeNumber(args["period-length"], "period-length")),
      1,
      10,
    );
  }
  if (args["reminder-time"]) {
    if (!/^\d{2}:\d{2}$/.test(args["reminder-time"])) {
      throw new Error("Invalid --reminder-time. Expected HH:MM.");
    }
    user.settings.reminderTime = args["reminder-time"];
  }
  if (args["delivery-mode"]) {
    const mode = String(args["delivery-mode"]);
    if (!["none", "announce", "webhook"].includes(mode)) {
      throw new Error("Invalid --delivery-mode. Use none, announce, or webhook.");
    }
    user.settings.deliveryMode = mode;
  }
  if (args["delivery-channel"]) {
    user.settings.deliveryChannel = String(args["delivery-channel"]);
  }
  if (args["delivery-to"]) {
    user.settings.deliveryTo = String(args["delivery-to"]);
  }
  if (args["delivery-webhook"]) {
    user.settings.deliveryWebhook = String(args["delivery-webhook"]);
  }
}

function requireUserKey(args) {
  const userKey = args.user;
  if (!userKey) {
    throw new Error("--user is required.");
  }
  return String(userKey);
}

function emit(output, asJson) {
  if (asJson) {
    process.stdout.write(`${JSON.stringify(output, null, 2)}\n`);
    return;
  }
  process.stdout.write(renderText(output));
}

function renderText(output) {
  if (output.command === "status") {
    if (!output.result.hasData) {
      return `${output.result.message}\n`;
    }
    return [
      `用户: ${output.result.userKey}`,
      `当前阶段: ${output.result.currentPhase.label}`,
      `最近一次开始: ${output.result.lastStartDate}`,
      `预计下次开始: ${output.result.predictedNextStart}`,
      `距上次开始: ${output.result.daysSinceLastStart} 天`,
      `距下次开始: ${output.result.daysUntilNextStart} 天`,
      `提醒日期: ${output.result.reminder ? output.result.reminder.reminderDate : "未生成"}`,
      `置信度: ${output.result.confidence}`,
    ].join("\n") + "\n";
  }

  if (output.command === "record") {
    return [
      `已记录: ${output.recordedDate}`,
      `是否重复: ${output.duplicate ? "是" : "否"}`,
      `预计下次开始: ${output.predictedNextStart || "暂无"}`,
      `提醒日期: ${output.reminder?.reminderDate || "暂无"}`,
    ].join("\n") + "\n";
  }

  if (output.command === "configure") {
    return `已更新设置，当前提醒方式为 ${output.settings.deliveryMode}。\n`;
  }

  if (output.command === "reminder-plan") {
    if (!output.plan) {
      return `${output.message}\n`;
    }
    return [
      `提醒作业: ${output.plan.jobName}`,
      `提醒日期: ${output.plan.reminderDate} ${output.plan.reminderTime}`,
      `UTC 时间: ${output.plan.scheduleAt}`,
      `提醒内容: ${output.plan.reminderText}`,
    ].join("\n") + "\n";
  }

  if (output.command === "history") {
    return `${output.records.map((record) => record.startDate).join("\n")}\n`;
  }

  return `${JSON.stringify(output, null, 2)}\n`;
}

function commandRecord(store, args) {
  const userKey = requireUserKey(args);
  const user = ensureUser(store, userKey);
  applySettingOverrides(user, args);
  const recordDate = args.date
    ? formatDate(parseDateString(args.date))
    : currentDateInTimeZone(user.settings.timezone);
  const duplicate = user.records.some((record) => record.startDate === recordDate);
  if (!duplicate) {
    user.records.push({
      startDate: recordDate,
      source: args.source ? String(args.source) : "user",
      recordedAt: new Date().toISOString(),
    });
  }
  const stats = computeUserStats(userKey, user);
  return {
    command: "record",
    userKey,
    duplicate,
    recordedDate: recordDate,
    predictedNextStart: stats.predictedNextStart,
    cycleLengthDays: stats.predictedCycleLength,
    confidence: stats.confidence,
    reminder: stats.reminder,
    historyCount: user.records.length,
  };
}

function commandStatus(store, args) {
  const userKey = requireUserKey(args);
  const user = ensureUser(store, userKey);
  const today = args.today
    ? formatDate(parseDateString(args.today))
    : currentDateInTimeZone(user.settings.timezone);
  return {
    command: "status",
    result: buildStatus(userKey, user, today),
  };
}

function commandPredict(store, args) {
  const userKey = requireUserKey(args);
  const user = ensureUser(store, userKey);
  const stats = computeUserStats(userKey, user);
  return {
    command: "predict",
    userKey,
    stats,
  };
}

function commandHistory(store, args) {
  const userKey = requireUserKey(args);
  const user = ensureUser(store, userKey);
  user.records = sanitizeRecords(user.records);
  return {
    command: "history",
    userKey,
    records: user.records,
  };
}

function commandConfigure(store, args) {
  const userKey = requireUserKey(args);
  const user = ensureUser(store, userKey);
  applySettingOverrides(user, args);
  const stats = computeUserStats(userKey, user);
  return {
    command: "configure",
    userKey,
    settings: user.settings,
    reminder: stats.reminder,
    confidence: stats.confidence,
  };
}

function commandReminderPlan(store, args) {
  const userKey = requireUserKey(args);
  const user = ensureUser(store, userKey);
  const stats = computeUserStats(userKey, user);
  if (!stats.reminder) {
    return {
      command: "reminder-plan",
      userKey,
      plan: null,
      message: "当前没有足够的数据生成提醒计划。",
    };
  }

  const deliveryReady = Boolean(
    stats.reminder.delivery.mode !== "none" &&
      ((stats.reminder.delivery.mode === "announce" &&
        stats.reminder.delivery.channel &&
        stats.reminder.delivery.to) ||
        (stats.reminder.delivery.mode === "webhook" && stats.reminder.delivery.webhook)),
  );

  return {
    command: "reminder-plan",
    userKey,
    plan: {
      ...stats.reminder,
      deliveryReady,
      agentTurnMessage: `Use $period-care-assistant to send a short caring reminder to user ${userKey}. Predicted next period start date: ${stats.reminder.predictedNextStart}. Reminder text: ${stats.reminder.reminderText}`,
    },
  };
}

export function execute(argv = process.argv.slice(2), options = {}) {
  const args = parseArgs(argv);
  const command = args._[0];
  if (!command || ["help", "--help", "-h"].includes(command)) {
    return {
      exitCode: 0,
      output: {
        command: "help",
        usage:
          "record|status|predict|history|configure|reminder-plan with --user <userKey> and optional --json",
      },
    };
  }

  const storePath = options.storePath || DEFAULT_STORE_PATH;
  const [REDACTED_SECRET] || requiredSecret();
  const store = loadStore(storePath, secret);
  let output;

  switch (command) {
    case "record":
      output = commandRecord(store, args);
      saveStore(store, storePath, secret);
      break;
    case "status":
      output = commandStatus(store, args);
      break;
    case "predict":
      output = commandPredict(store, args);
      break;
    case "history":
      output = commandHistory(store, args);
      break;
    case "configure":
      output = commandConfigure(store, args);
      saveStore(store, storePath, secret);
      break;
    case "reminder-plan":
      output = commandReminderPlan(store, args);
      break;
    default:
      throw new Error(`Unknown command '${command}'.`);
  }

  return { exitCode: 0, output, asJson: Boolean(args.json) };
}

export function main(argv = process.argv.slice(2)) {
  try {
    const { output, asJson } = execute(argv);
    emit(output, asJson);
  } catch (error) {
    process.stderr.write(`${error.message}\n`);
    process.exitCode = 1;
  }
}

if (process.argv[1] === __filename) {
  main();
}


path: scripts/period_tracker.test.mjs
content: import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";

import { execute } from "./period_tracker.mjs";

function tempStore() {
  return path.join(fs.mkdtempSync(path.join(os.tmpdir(), "period-care-")), "tracker.enc");
}

const [REDACTED_SECRET]";

test("record stores data and predicts next start", () => {
  const storePath = tempStore();

  execute(["record", "--user", "demo:alice", "--date", "2026-01-01"], { storePath, secret });
  execute(["record", "--user", "demo:alice", "--date", "2026-01-29"], { storePath, secret });
  const result = execute(["status", "--user", "demo:alice", "--today", "2026-02-10"], {
    storePath,
    secret,
  }).output.result;

  assert.equal(result.lastStartDate, "2026-01-29");
  assert.equal(result.predictedNextStart, "2026-02-26");
  assert.equal(result.currentPhase.label, "卵泡期");
});

test("duplicate record does not create a second entry", () => {
  const storePath = tempStore();

  execute(["record", "--user", "demo:bob", "--date", "2026-03-01"], { storePath, secret });
  const duplicate = execute(["record", "--user", "demo:bob", "--date", "2026-03-01"], {
    storePath,
    secret,
  }).output;
  const history = execute(["history", "--user", "demo:bob"], { storePath, secret }).output.records;

  assert.equal(duplicate.duplicate, true);
  assert.equal(history.length, 1);
});

test("configure reminder plan generates a deterministic job", () => {
  const storePath = tempStore();

  execute(["record", "--user", "demo:carol", "--date", "2026-02-01"], { storePath, secret });
  execute(["record", "--user", "demo:carol", "--date", "2026-03-01"], { storePath, secret });
  execute(
    [
      "configure",
      "--user",
      "demo:carol",
      "--timezone",
      "Asia/Shanghai",
      "--reminder-days",
      "5",
      "--delivery-mode",
      "webhook",
      "--delivery-webhook",
      "https://example.invalid/dingtalk",
    ],
    { storePath, secret },
  );

  const plan = execute(["reminder-plan", "--user", "demo:carol"], { storePath, secret }).output.plan;

  assert.equal(plan.jobName.startsWith("period-reminder-"), true);
  assert.equal(plan.predictedNextStart, "2026-03-29");
  assert.equal(plan.reminderDate, "2026-03-24");
  assert.equal(plan.delivery.mode, "webhook");
  assert.equal(plan.delivery.webhook, "https://example.invalid/dingtalk");
  assert.equal(plan.deliveryReady, true);
});


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (HIGH)