1. cron に「隔週」は書けない
crontab に「2 週間ごと」と書きたくなって、*/14 と書いた人は多いと思う。日の欄に */14 と書くと、cron は「1 日、15 日、29 日」に動く。月が替わると 1 日からやり直す。2 月なら 1 日、15 日の次は 3 月 1 日(2 週間)、その次は 15 日、29 日、そして 4 月 1 日(3 日後)。「2 週間ごと」にはならない。
同じことを Kairos で書いて、動きを見ておく。「各月の 1 日から数えて 14 日おき」は、その月の何日目かを返す ordinalIn(day, month, d) を使って書ける:
premise JP { calendar-system: Gregorian; tz: "Asia/Tokyo"; wkst: Mon }
@JP
everyDay |> filter(d => (ordinalIn(day, month, d) - 1) mod 14 == 0)
$ kairos list --from 2026-01-01 --to 2026-04-02 cron14.kairos 2026-01-01 2026-01-15 2026-01-29 2026-02-01 2026-02-15 2026-03-01 2026-03-15 2026-03-29 2026-04-01
1 月 29 日の 3 日後に 2 月 1 日、3 月 29 日の 3 日後に 4 月 1 日。これは */14 の動きを忠実に書いただけで、隔週ではない。cron が悪いのではなく、「月の中の何日目か」で数えている限り、月境界でリセットされるのは当然である。Debian の crontab のマニュアルは、この制約の回避策として「epoch 秒を 86400×14 で割った余りで判定するシェル式」を公式に例示している。
2. 「ISO 週番号の偶奇」ハックは、53 週の年で壊れる
もう一つよく見る回避策が、「その週の ISO 週番号が偶数(奇数)のときだけ動かす」——date +%V の値を 2 で割る、というものだ。1 年は 52 週だから、偶数週だけ動かせば隔週になる、という理屈である。
2026 年は、それが壊れる年である。2026 年 1 月 1 日は木曜日で、ISO 週番号の規則では53 週ある年になる。年末年始の金曜日の週番号を並べると:
$ for d in 2026-12-18 2026-12-25 2027-01-01 2027-01-08 2027-01-15; do echo "$d W$(date -d $d +%V)"; done 2026-12-18 W51 2026-12-25 W52 2027-01-01 W53 2027-01-08 W01 2027-01-15 W02
W53 の次が W01。奇数週に動かす設定なら、1 月 1 日と 1 月 8 日に2 週連続で動く。偶数週なら12 月 25 日の次が 1 月 15 日で、3 週空く。給与を隔週金曜に払う会社なら、どちらも事故である。これも週番号が「年の中で何週目か」で数えている限り、年境界で起きる。
3. Kairos では、起点から数える
Kairos の stride(n, from:) は、入力の点を、起点 from: から数えて n 個ごとに残す。月の中の何日目か、年の中の何週目か、といった枠の中の位置は見ない。だから枠の境界でリセットされない。
10 日ごと:
premise JP { calendar-system: Gregorian; tz: "Asia/Tokyo"; wkst: Mon }
@JP
everyDay |> stride(10, from: 2026-01-05)
$ kairos list --from 2026-01-01 --to 2026-03-01 every10.kairos 2026-01-05 2026-01-15 2026-01-25 2026-02-04 2026-02-14 2026-02-24
1 月 25 日の 10 日後は 2 月 4 日。月が替わっても 10 日である。
隔週の金曜(2026 年 1 月 9 日を起点に):
@JP everyDay |> filter(d => weekday(d) == Fri) |> stride(2, from: 2026-01-09)
$ kairos list --from 2026-01-01 --to 2027-01-01 biweekly.kairos | wc -l 26 $ kairos list --from 2026-12-01 --to 2027-02-01 biweekly.kairos 2026-12-11 2026-12-25 2027-01-08 2027-01-22
2026 年に 26 回。年をまたいでも 12 月 25 日の 2 週間後が 1 月 8 日で、W53 のことは何も起きない。式のどこにも週番号が出てこないからである。
読み方を一つだけ。stride(2, …) は「2 個ごと」であって「2 週ごと」ではない。何を数えるかは前の段が決める。前の段が filter(d => weekday(d) == Fri) で「金曜の列」にしてあるから、その 2 個ごと=隔週金曜になる。前の段を営業日の列にすれば「3 営業日ごと」、毎日の列なら「10 日ごと」。同じ stride で、数える対象だけが変わる。
4. from: は位相
from: は省略できない。書き忘れると静的エラーで止まる。理由は、隔週には「どちらの週か」という位相があり、それを決めるのは起点しかないからだ。1 月 9 日起点と 1 月 16 日起点は、どちらも「隔週金曜」で、どちらも正しく、出る日は全部違う。ISO 週の偶奇ハックが壊れたのは、位相を「年の中の週番号」という、年ごとに付け直される座標に預けていたからで、from: は位相を日付で固定する。
from: は入力の点でなくてもよい。from: 2026-01-01 と書けば「1 月 1 日以降の最初の金曜」が数え始めになる。起点より前の点は出ない。
5. 祝日なら前営業日
給与なら、隔週金曜が祝日のときは前営業日に払いたい。営業日の定義を premise に足して、roll(Preceding, on: bizDay) を最後に置く(roll と shift の違いは「寄せる」と「数える」は別の操作であるを参照):
premise JP {
calendar-system: Gregorian; tz: "Asia/Tokyo"; wkst: Mon
national = [2026-01-01, 2026-01-12, 2026-02-11, 2026-02-23, 2026-03-20, 2026-04-29, 2026-05-03..2026-05-06,
2026-07-20, 2026-08-11, 2026-09-21..2026-09-23, 2026-10-12, 2026-11-03, 2026-11-23] covering: 2026..2026
satSun = everyDay |> filter(d => weekday(d) == Sat or weekday(d) == Sun)
bizDay = everyDay \ (satSun | national)
}
@JP
everyDay |> filter(d => weekday(d) == Fri) |> stride(2, from: 2026-01-09) |> roll(Preceding, on: bizDay)
$ kairos list --from 2026-01-01 --to 2026-05-01 biweekly-roll.kairos 2026-01-09 2026-01-23 2026-02-06 2026-02-20 2026-03-06 2026-03-19 2026-04-03 2026-04-17 # ⚠ 範囲外 2026-01-01..2026-01-02(JP.national covering 2026-01-01..2026-12-31)
3 月 20 日(金)は春分の日なので、19 日(木)へ寄った。数える段(stride)と丸める段(roll)が分かれているので、「隔週」の位相は祝日で崩れない——次は 4 月 3 日で、19 日から数え直してはいない。
末尾の注記は、1 月 1 日が祝日で、もしそこに点が落ちれば「前営業日」は 2025 年——祝日表が覆っていない年——になる、という言語からの一言である(この定義で 1 月 1 日に点は落ちないので実害はない)。この注記の読み方は前回の 25 日払いの例に書いた。
6. 「第 N」と「N ごと」
紛らわしいので、境目を書いておく。
- 枠の中の位置で言う——「各月の第 2 金曜」「各月の 1 日と 15 日」。枠(月)が替わるたびに 数え直す。Kairos では
within(month) |> nth(2)や、§1 のordinalInの形。cron の*/14が本当に欲しかったものが、実はこちらだったこともある(「毎月 1 日と 15 日」なら*/14より1,15と書くほうが素直で、Kairos でもordinalIn(day, month, d)を使って書ける)。 - 起点から数えて言う——「隔週」「10 日ごと」「3 営業日ごと」。枠の境界を無視して数え続ける。 Kairos では
stride(n, from:)。
cron は前者しか持っていない。後者を前者で書こうとしたのが */14 と週番号ハックで、月末と年末で綻ぶのは仕組みからの帰結である。
7. 動かす
前回までの型がそのまま使える。月末最終営業日の自動実行まで通すのrun-if-today.sh に、この定義ファイルを渡すだけ:
0 9 * * 5 cd /srv/payroll && ./run-if-today.sh biweekly-roll.kairos ./pay.sh
cron 側は「毎週金曜 9 時」に叩き、動くかどうかの判定は定義に委ねる。隔週の位相も、祝日の前倒しも、cron には書かない。
まとめ
*/14は「月の何日目か」で数えるので月境界でリセットされる。ISO 週番号の偶奇は「年の何週目か」で 数えるので 53 週ある年(2026 年)の年末で壊れる——どちらも枠の中の座標で数えた帰結stride(n, from:)は起点から数える。月も年もまたぐ。2026 年の隔週金曜は 26 回、年をまたいで 12/25 → 1/8- 何を数えるかは前の段が決める(金曜の列なら隔週、営業日の列なら N 営業日ごと)
from:は位相。省略はできない- 祝日の前倒しは最後に
roll(Preceding, on: bizDay)——数える段と丸める段は分かれている
▶ Playground で定義を実行する——2026 年の隔週金曜 26 点が出る。コメントにある「10 日ごと」「*/14 と同じ動き」「祝日なら前営業日」の行と入れ替えて試せる。from: を 1 月 16 日に変えると、全部が 1 週ずれる。ブラウザ内で完結し、何も送信しない。
Kairos はスケジュール定義言語です。仕様と参照実装はGitHub で公開しています。

コメント