diff --git a/CHANGELOG.md b/CHANGELOG.md index cb7b911..a1c4ea9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to Agent Wallclock are documented here. Format loosely follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning starts at **0.1.0** for the first public QA-ready release. +## [Unreleased] + +### Added + +- **CLI:** `wallclock circadian on|off|status` — opt-in Circadian context in Temporal Briefing (default off) +- **Core:** `config.json` prefs (`circadianEnabled`) separate from `store.json`; `classifyCircadian` fixed local-hour bands +- **Briefing:** optional `## Circadian` block (band / day / non-moralizing tone hint) when enabled; compact line `circadian ` + ## [0.1.0] — 2026-07-31 First post-QA hardening release batch (`fix/pass-hardening-100`). diff --git a/README.md b/README.md index b3151d9..7f3f71f 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,7 @@ wallclock effort start auth-rewrite wallclock session start wallclock brief # print wallclock brief --copy # copy to clipboard when available +wallclock circadian on # opt-in: add Circadian band/tone to brief (default off) ``` Example output (real CLI shape; durations use `d`/`h`/`m`/`s`): @@ -168,6 +169,7 @@ When the skill/rule is on, Cursor should call `get_briefing` (or run the CLI) in |---------|---------| | `wallclock now` | Local date, time, timezone, weekday, ISO | | `wallclock brief` | Full Temporal Briefing (`--copy`, `--json`, `--compact`) | +| `wallclock circadian on\|off\|status` | Opt-in Circadian block in briefing (default off; prefs in `config.json`) | | `wallclock doctor [--repair]` | Health checks (store, builds, permissions) | | `wallclock where` | Show store path and config hints | | `wallclock --version` | Package version | @@ -204,7 +206,7 @@ Full help: `wallclock --help`. Troubleshooting: [`TROUBLESHOOTING.md`](TROUBLESH ## Privacy -- State lives only under `~/.agent-wallclock/` (JSON; directory `0700`, file `0600` when the OS allows). +- State lives only under `~/.agent-wallclock/` (`store.json` ledger + optional `config.json` prefs; directory `0700`, file `0600` when the OS allows). - CLI and MCP make **no network calls**. - **Clipboard:** `wallclock brief --copy` puts briefing text on your local clipboard; you choose when to paste. - **Paste trust:** pasting a briefing into ChatGPT, Claude, or similar **uploads** that time data to the host cloud. diff --git a/adapters/chatgpt-custom-instructions.md b/adapters/chatgpt-custom-instructions.md index cf553d0..160e90b 100644 --- a/adapters/chatgpt-custom-instructions.md +++ b/adapters/chatgpt-custom-instructions.md @@ -11,7 +11,8 @@ Rules: - Check **Generated at** / **Stale after**. If **Generated-at** is older than the stated freshness window (default 15 minutes), ask the user to paste a refreshed `wallclock brief` before making time-based claims. - Never invent time of day or how long the user has been working. - If duration or clock fields are missing, say unknown — do not guess. -- Do not tell the user to go to sleep or that they have been at something for days/hours unless the briefing supports it. +- Do not tell the user to go to sleep or that they have been at something for days/hours unless the briefing supports it. Never issue sleep/rest commands from guessed time. +- If a **Circadian** section is present in a fresh briefing, use band/day/tone for reply energy; if absent, do not invent circadian state. - A new chat is not a new project if an active effort shows prior logged work. When no briefing is present, avoid time-based coaching and ask for `wallclock brief` if temporal claims matter. diff --git a/adapters/claude-project-instructions.md b/adapters/claude-project-instructions.md index b1dbab6..f4a283a 100644 --- a/adapters/claude-project-instructions.md +++ b/adapters/claude-project-instructions.md @@ -11,6 +11,7 @@ When a Temporal Briefing is available (pasted or via MCP tools `get_now` / `get_ - Use it as the only source for wall-clock time, session age, and effort duration. - Check **Generated at** / **Stale after**. If **Generated-at** is older than the stated freshness window (default 15 minutes), call `get_briefing` again or ask for a refreshed paste before time-based advice. - Never invent circadian context (“it’s late”, “go to sleep”) against the briefing’s local time. +- If a **Circadian** section is present in a fresh briefing, use band/day/tone for reply energy; if absent, do not invent circadian state. Never issue sleep/rest commands from guessed time. - Never invent session length (“you’ve been at this for days”) against session age. - Never treat a fresh thread as zero history when an effort has accumulated logged time. - Missing fields → say unknown or ask; never guess. diff --git a/adapters/cursor-skill/SKILL.md b/adapters/cursor-skill/SKILL.md index 4c73a1c..d1f0872 100644 --- a/adapters/cursor-skill/SKILL.md +++ b/adapters/cursor-skill/SKILL.md @@ -23,8 +23,9 @@ description: >- - session age (“hours”, “days”) - effort history on multi-week work 4. If briefing data is missing, say **unknown** — never guess from priors. -5. Do not advise sleep or “you’ve done enough” based on invented duration. -6. Write tools (`start_effort`, `log_session`) require `AGENT_WALLCLOCK_WRITES=1` — default off. +5. Do not advise sleep or “you’ve done enough” based on invented duration. Never issue sleep/rest commands from guessed time. +6. If a **Circadian** section is present in a fresh briefing, use band/day/tone for reply energy; if absent, do not invent circadian state. +7. Write tools (`start_effort`, `log_session`) require `AGENT_WALLCLOCK_WRITES=1` — default off. ## Install skill @@ -38,5 +39,5 @@ Add the rule fragment from [`rule.md`](rule.md) to Cursor user rules if desired. ## Rule fragment (user rules) ``` -Temporal context: Use Agent Wallclock. Never invent clock time, session age, or effort duration. Prefer MCP get_briefing or npm exec / absolute node path — not bare wallclock. Refresh if stale; missing fields are unknown. +Temporal context: Use Agent Wallclock. Never invent clock time, session age, or effort duration. Prefer MCP get_briefing or npm exec / absolute node path — not bare wallclock. Refresh if stale; missing fields are unknown. Use Circadian only when present; never invent circadian state or sleep advice. ``` diff --git a/adapters/cursor-skill/rule.md b/adapters/cursor-skill/rule.md index 65a33af..6807700 100644 --- a/adapters/cursor-skill/rule.md +++ b/adapters/cursor-skill/rule.md @@ -1,3 +1,3 @@ # Cursor user rule fragment — Agent Wallclock -Temporal context: Use Agent Wallclock. Never invent clock time, session age, or effort duration. **MCP-first:** call `get_briefing` (or `get_session_status` / `get_timeline`) when the server is configured. Otherwise run `npm exec -w @agent-wallclock/cli -- wallclock brief` or `node /ABSOLUTE/PATH/TO/agent-wallclock/packages/cli/dist/bin.js brief` — do not assume bare `wallclock` is on PATH. Refresh if **Generated at** is outside the freshness window (default 15m). If fields are missing, say unknown. Do not invent sleep/rest advice from guessed time. +Temporal context: Use Agent Wallclock. Never invent clock time, session age, or effort duration. **MCP-first:** call `get_briefing` (or `get_session_status` / `get_timeline`) when the server is configured. Otherwise run `npm exec -w @agent-wallclock/cli -- wallclock brief` or `node /ABSOLUTE/PATH/TO/agent-wallclock/packages/cli/dist/bin.js brief` — do not assume bare `wallclock` is on PATH. Refresh if **Generated at** is outside the freshness window (default 15m). If fields are missing, say unknown. If a Circadian section is present, use band/day/tone for reply energy; if absent, do not invent circadian state. Never invent sleep/rest advice from guessed time. diff --git a/adapters/generic-system-prompt.md b/adapters/generic-system-prompt.md index 5f2607b..f61ed33 100644 --- a/adapters/generic-system-prompt.md +++ b/adapters/generic-system-prompt.md @@ -8,9 +8,10 @@ Paste this into any model’s system prompt or custom instructions. When a Tempo 2. Check **Generated at** / **Stale after**. If **Generated-at** is older than the stated freshness window (default 15 minutes), request a refreshed briefing before time-based claims. 3. Never invent the time of day, how long the user has been working in this chat, or how long a project has been underway. 4. If briefing fields are missing, say **unknown** or ask — do not guess from training priors. -5. Do not advise the user to sleep, stop, or “take a break because it is late” unless the briefing’s local time and session/effort data support that claim. -6. Do not equate a new chat with a new project when an active effort shows multi-day logged work. -7. Prefer precise durations from the briefing (e.g. “12 minutes”) over vague claims (“hours”, “days”) when numbers are available. +5. Do not advise the user to sleep, stop, or “take a break because it is late” unless the briefing’s local time and session/effort data support that claim. Never issue sleep/rest commands from guessed time. +6. If a **Circadian** section is present in a fresh briefing, use its band/day/tone for reply energy. If absent, do not invent circadian state. +7. Do not equate a new chat with a new project when an active effort shows multi-day logged work. +8. Prefer precise durations from the briefing (e.g. “12 minutes”) over vague claims (“hours”, “days”) when numbers are available. ## When no briefing is available @@ -18,4 +19,4 @@ Say that wall-clock and effort duration are unknown, and suggest the user run `w ## MODEL_RULES alignment -Trust only the Temporal Briefing for clock, session age, and effort duration. Never invent time. Missing fields → unknown. Refresh if stale. +Trust only the Temporal Briefing for clock, session age, and effort duration. Never invent time. If a Circadian block is present, tone may follow it; if absent, do not invent circadian state. Never moralize about sleep from the clock alone. Missing fields → unknown. Refresh if stale. diff --git a/docs/architecture.md b/docs/architecture.md index 6f8572d..b1b1aad 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -22,6 +22,7 @@ Agent Wallclock is a **local-only** temporal context layer for language models. │ ┌───────────────────────────▼─────────────────────────────────┐ │ ~/.agent-wallclock/store.json (+ store.lock) │ +│ ~/.agent-wallclock/config.json (prefs; e.g. circadian) │ │ system clock (Date / Intl) │ └─────────────────────────────────────────────────────────────┘ ``` @@ -36,9 +37,10 @@ Agent Wallclock is a **local-only** temporal context layer for language models. - **Location:** `~/.agent-wallclock/` by default; override with `AGENT_WALLCLOCK_HOME`. - **File:** `store.json` — efforts, sessions, active pointers, schema version. +- **Prefs:** `config.json` — local toggles (e.g. `circadianEnabled`); separate from the ledger so effort backups do not flip prefs. - **Lock:** `store.lock` — exclusive lock for atomic read-modify-write (CLI + MCP). - **Permissions:** directory `0700`, file `0600` (best effort on Unix). -- **Backup/restore:** `wallclock store backup|restore` for user-managed snapshots. +- **Backup/restore:** `wallclock store backup|restore` for user-managed snapshots of `store.json`. Store operations are synchronous and local. Corruption triggers `StoreCorruptError`; `wallclock doctor --repair` attempts normalization. @@ -50,8 +52,9 @@ The **Temporal Briefing** is markdown (or JSON with `--json`) built from: 2. Open session age (if any) 3. Active effort logged time and calendar age 4. **Freshness** metadata: `Generated at`, `Stale after` (default 15 minutes via `AGENT_WALLCLOCK_STALE_AFTER_MS`) +5. Optional **Circadian** block (band / weekday-weekend / tone hint) — only when `wallclock circadian on` has set `circadianEnabled` in `config.json` (default off) -`MODEL_RULES` in the briefing instruct models to trust the briefing only while fresh and never invent durations. +`MODEL_RULES` in the briefing instruct models to trust the briefing only while fresh, never invent durations, and use Circadian tone only when that section is present. Adapters in `adapters/` propagate the same rules into host-specific instructions. diff --git a/packages/cli/src/bin.test.ts b/packages/cli/src/bin.test.ts index 074c81d..7ebfda0 100644 --- a/packages/cli/src/bin.test.ts +++ b/packages/cli/src/bin.test.ts @@ -57,4 +57,48 @@ describe("wallclock CLI", () => { const usage = run(["effort", "log"]); assert.equal(usage.status, 2, usage.stderr || usage.stdout); }); + + it("circadian defaults off and opt-in adds briefing block", () => { + home = mkdtempSync(join(tmpdir(), "wallclock-cli-test-")); + assert.equal(run(["init"]).status, 0); + + const statusOff = run(["circadian", "status"]); + assert.equal(statusOff.status, 0, statusOff.stderr || statusOff.stdout); + assert.match(statusOff.stdout, /circadian: off/); + + const briefOff = run(["brief"]); + assert.equal(briefOff.status, 0, briefOff.stderr || briefOff.stdout); + assert.doesNotMatch(briefOff.stdout, /## Circadian/); + + const on = run(["circadian", "on"]); + assert.equal(on.status, 0, on.stderr || on.stdout); + assert.match(on.stdout, /circadian: on/); + + const statusOn = run(["circadian", "status"]); + assert.equal(statusOn.status, 0, statusOn.stderr || statusOn.stdout); + assert.match(statusOn.stdout, /circadian: on/); + assert.match(statusOn.stdout, /band: /); + assert.match(statusOn.stdout, /day: /); + + const briefOn = run(["brief"]); + assert.equal(briefOn.status, 0, briefOn.stderr || briefOn.stdout); + assert.match(briefOn.stdout, /## Circadian/); + assert.match(briefOn.stdout, /Band: /); + assert.match(briefOn.stdout, /Day: /); + assert.match(briefOn.stdout, /Tone: /); + + const json = run(["brief", "--json"]); + assert.equal(json.status, 0, json.stderr || json.stdout); + const parsed = JSON.parse(json.stdout) as { circadian: { band: string } | null }; + assert.ok(parsed.circadian); + assert.ok(parsed.circadian?.band); + + const compact = run(["brief", "--compact"]); + assert.equal(compact.status, 0, compact.stderr || compact.stdout); + assert.match(compact.stdout, /circadian \w+ \w+/); + + assert.equal(run(["circadian", "off"]).status, 0); + const briefAgain = run(["brief"]); + assert.doesNotMatch(briefAgain.stdout, /## Circadian/); + }); }); diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts index af71002..962c8a6 100644 --- a/packages/cli/src/bin.ts +++ b/packages/cli/src/bin.ts @@ -29,6 +29,9 @@ import { restoreStore, runDoctor, loadConfig, + loadPrefs, + savePrefs, + classifyCircadian, classifyError, CliError, ExitCode, @@ -66,6 +69,7 @@ function printHelp(): void { Usage: wallclock now wallclock brief [--copy] [--json] [--compact] + wallclock circadian on|off|status wallclock effort start wallclock effort list [--json] [--all] wallclock effort status [name] @@ -93,6 +97,8 @@ Examples: wallclock session start wallclock brief --copy wallclock brief --json | jq .generatedAt + wallclock circadian on + wallclock circadian status wallclock timeline 10 --effort auth-rewrite wallclock mcp-config --print cursor --check wallclock doctor @@ -173,20 +179,25 @@ function cmdNow(): void { console.log(`ISO (UTC): ${now.iso}`); } +function briefingOpts() { + return { storeDir: STORE_DIR }; +} + function cmdBrief(args: string[]): void { const copy = takeFlag(args, "--copy"); const asJson = takeFlag(args, "--json"); const compact = takeFlag(args, "--compact"); const store = loadStore(STORE_DIR); + const opts = briefingOpts(); let text: string; if (asJson) { - const input = buildBriefingInput(store); + const input = buildBriefingInput(store, opts); text = `${JSON.stringify({ ...input, modelRules: MODEL_RULES }, null, 2)}\n`; } else if (compact) { - text = `${renderBriefingCompact(store)}\n`; + text = `${renderBriefingCompact(store, opts)}\n`; } else { - text = renderBriefing(store); + text = renderBriefing(store, opts); } if (copy) { @@ -638,16 +649,43 @@ function cmdInit(): void { } } +function cmdCircadian(args: string[]): void { + const action = args[0]; + if (action === "on") { + savePrefs({ circadianEnabled: true }, STORE_DIR); + console.log("circadian: on"); + return; + } + if (action === "off") { + savePrefs({ circadianEnabled: false }, STORE_DIR); + console.log("circadian: off"); + return; + } + if (action === "status") { + const enabled = loadPrefs(STORE_DIR).circadianEnabled; + console.log(`circadian: ${enabled ? "on" : "off"}`); + if (enabled) { + const ctx = classifyCircadian(); + console.log(`band: ${ctx.band}`); + console.log(`day: ${ctx.dayKind}`); + console.log(`local hour: ${String(ctx.localHour).padStart(2, "0")}`); + } + return; + } + throw new CliError("Usage: wallclock circadian on|off|status", ExitCode.USAGE); +} + function cmdCompletion(args: string[]): void { const shell = args[0]; if (shell === "bash") { console.log(`# wallclock bash completion _wallclock() { local cur="\${COMP_WORDS[COMP_CWORD]}" - local cmds="now brief effort session timeline store doctor where mcp-config init completion help" + local cmds="now brief circadian effort session timeline store doctor where mcp-config init completion help" local effort_subs="start list status log rename archive unarchive delete" local session_subs="start end status" local store_subs="backup restore" + local circadian_subs="on off status" if [[ \${COMP_CWORD} -eq 1 ]]; then COMPREPLY=( $(compgen -W "\$cmds" -- "\$cur") ) elif [[ \${COMP_WORDS[1]} == effort && \${COMP_CWORD} -eq 2 ]]; then @@ -656,6 +694,8 @@ _wallclock() { COMPREPLY=( $(compgen -W "\$session_subs" -- "\$cur") ) elif [[ \${COMP_WORDS[1]} == store && \${COMP_CWORD} -eq 2 ]]; then COMPREPLY=( $(compgen -W "\$store_subs" -- "\$cur") ) + elif [[ \${COMP_WORDS[1]} == circadian && \${COMP_CWORD} -eq 2 ]]; then + COMPREPLY=( $(compgen -W "\$circadian_subs" -- "\$cur") ) elif [[ \${COMP_WORDS[1]} == mcp-config ]]; then COMPREPLY=( $(compgen -W "--print --check claude cursor vscode" -- "\$cur") ) elif [[ \${COMP_WORDS[1]} == completion ]]; then @@ -670,12 +710,13 @@ complete -F _wallclock wallclock console.log(`#compdef wallclock _wallclock() { local -a cmds - cmds=(now brief effort session timeline store doctor where mcp-config init completion help) + cmds=(now brief circadian effort session timeline store doctor where mcp-config init completion help) _arguments '1:command:(\${cmds})' '*::arg:->args' case \$words[1] in effort) _values 'effort' start list status log rename archive unarchive delete ;; session) _values 'session' start end status ;; store) _values 'store' backup restore ;; + circadian) _values 'circadian' on off status ;; mcp-config) _values 'mcp' --print --check claude cursor vscode ;; completion) _values 'shell' bash zsh ;; esac @@ -715,6 +756,9 @@ function main(argv: string[]): void { case "brief": cmdBrief(rest); break; + case "circadian": + cmdCircadian(rest); + break; case "effort": cmdEffort(rest); break; diff --git a/packages/core/README.md b/packages/core/README.md index 661ea2e..16ef04c 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -10,11 +10,12 @@ Core library for Agent Wallclock: system clock, local JSON store, efforts, sessi - `loadStore`, `updateStore`, `initStore`, `backupStore`, `restoreStore` - Effort and session operations (`startEffort`, `startSession`, `endSession`, …) - `renderBriefing`, `renderBriefingCompact`, `buildBriefingInput`, `MODEL_RULES` +- `classifyCircadian`, `loadPrefs`, `savePrefs` (opt-in circadian; `config.json`) - `runDoctor`, `loadConfig`, typed errors (`CliError`, `StoreCorruptError`, …) ## Store -Default directory: `~/.agent-wallclock/` (`store.json`, `store.lock`). Override with `AGENT_WALLCLOCK_HOME`. +Default directory: `~/.agent-wallclock/` (`store.json`, `store.lock`, optional `config.json` prefs). Override with `AGENT_WALLCLOCK_HOME`. ## Config (environment) diff --git a/packages/core/src/brief.ts b/packages/core/src/brief.ts index 92a56b6..dda9b48 100644 --- a/packages/core/src/brief.ts +++ b/packages/core/src/brief.ts @@ -1,7 +1,10 @@ +import { classifyCircadian } from "./circadian.js"; import { getNow } from "./clock.js"; import { loadConfig } from "./config.js"; import { ageMs, formatDuration } from "./format.js"; +import { loadPrefs } from "./prefs.js"; import { getActiveSession } from "./session.js"; +import { getDefaultStoreDir } from "./store.js"; import type { BriefingInput, BriefingOptions, StoreData } from "./types.js"; /** @deprecated Prefer loadConfig().staleAfterMs */ @@ -18,9 +21,16 @@ export const MODEL_RULES = [ "Never invent time of day, how long the user has been working, or effort history.", "If a field is missing or unknown, say unknown or ask — do not guess.", "Do not give sleep, circadian, or \"you have done enough\" advice based on invented duration.", + "If a Circadian block is present, tone may follow its band/day/tone hint; still never invent time or fatigue, and never moralize about sleep from the clock alone.", "If Generated-at is older than the stated freshness window, ask for a refreshed briefing before time-based claims.", ].join(" "); +function resolveCircadianEnabled(opts: BriefingOptions): boolean { + if (opts.circadianEnabled != null) return opts.circadianEnabled; + const storeDir = opts.storeDir ?? getDefaultStoreDir(); + return loadPrefs(storeDir).circadianEnabled; +} + export function buildBriefingInput( store: StoreData, nowDateOrOpts: Date | BriefingOptions = new Date(), @@ -58,6 +68,9 @@ export function buildBriefingInput( } } + const circadianEnabled = resolveCircadianEnabled(opts); + const circadian = circadianEnabled ? classifyCircadian(nowDate) : null; + return { now, generatedAt: now.iso, @@ -71,6 +84,7 @@ export function buildBriefingInput( sessionAgeMs != null && sessionAgeMs >= openSessionWarnAfterMs, openSessionCapNote: sessionAgeMs != null && sessionAgeMs >= openSessionSoftCapMs, + circadian, }; } @@ -99,9 +113,19 @@ export function renderBriefing( `- Timezone: ${input.now.timezone}`, `- ISO (UTC): ${input.now.iso}`, "", - "## Active session", ]; + if (input.circadian) { + const hour = String(input.circadian.localHour).padStart(2, "0"); + lines.push("## Circadian"); + lines.push(`- Band: ${input.circadian.band} (local hour ${hour})`); + lines.push(`- Day: ${input.circadian.dayKind}`); + lines.push(`- Tone: ${input.circadian.toneHint}`); + lines.push(""); + } + + lines.push("## Active session"); + if (input.activeSession && input.sessionAgeMs != null) { lines.push(`- Status: open`); lines.push(`- Started: ${input.activeSession.startedAt} (UTC)`); @@ -166,10 +190,14 @@ export function renderBriefingCompact( const effort = input.activeEffort ? `${input.activeEffort.name} logged=${formatDuration(input.effortTotalMs)}` : "none"; - return [ + const lines = [ `now ${input.now.localDate} ${input.now.localTime} ${input.now.weekday} (${input.now.timezone})`, `iso(UTC) ${input.now.iso} stale-after ${formatDuration(input.staleAfterMs)}`, `session ${session}`, `effort ${effort}`, - ].join("\n"); + ]; + if (input.circadian) { + lines.push(`circadian ${input.circadian.band} ${input.circadian.dayKind}`); + } + return lines.join("\n"); } diff --git a/packages/core/src/circadian.test.ts b/packages/core/src/circadian.test.ts new file mode 100644 index 0000000..a0840e9 --- /dev/null +++ b/packages/core/src/circadian.test.ts @@ -0,0 +1,176 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { after, describe, it } from "node:test"; +import { + bandForLocalHour, + classifyCircadian, + dayKindForDate, + toneHintForBand, +} from "./circadian.js"; +import { + buildBriefingInput, + renderBriefing, + renderBriefingCompact, + MODEL_RULES, +} from "./brief.js"; +import { emptyStore } from "./store.js"; +import { defaultPrefs, getPrefsFilePath, loadPrefs, savePrefs } from "./prefs.js"; + +function localAt( + year: number, + month: number, + day: number, + hour: number, + minute = 0, +): Date { + return new Date(year, month - 1, day, hour, minute, 0, 0); +} + +describe("circadian bands", () => { + it("classifies fixed local-hour boundaries", () => { + assert.equal(bandForLocalHour(0), "night"); + assert.equal(bandForLocalHour(4), "night"); + assert.equal(bandForLocalHour(5), "morning"); + assert.equal(bandForLocalHour(11), "morning"); + assert.equal(bandForLocalHour(12), "afternoon"); + assert.equal(bandForLocalHour(16), "afternoon"); + assert.equal(bandForLocalHour(17), "evening"); + assert.equal(bandForLocalHour(21), "evening"); + assert.equal(bandForLocalHour(22), "night"); + assert.equal(bandForLocalHour(23), "night"); + + const nightEarly = classifyCircadian(localAt(2026, 3, 2, 4, 59)); + assert.equal(nightEarly.band, "night"); + assert.equal(nightEarly.localHour, 4); + + const morning = classifyCircadian(localAt(2026, 3, 2, 5, 0)); + assert.equal(morning.band, "morning"); + assert.equal(morning.localHour, 5); + + const afternoon = classifyCircadian(localAt(2026, 3, 2, 12, 0)); + assert.equal(afternoon.band, "afternoon"); + + const evening = classifyCircadian(localAt(2026, 3, 2, 17, 0)); + assert.equal(evening.band, "evening"); + + const nightLate = classifyCircadian(localAt(2026, 3, 2, 22, 0)); + assert.equal(nightLate.band, "night"); + }); + + it("distinguishes weekday vs weekend", () => { + // 2026-03-02 is Monday; 2026-03-01 is Sunday + assert.equal(dayKindForDate(localAt(2026, 3, 2, 10)), "weekday"); + assert.equal(dayKindForDate(localAt(2026, 3, 1, 10)), "weekend"); + assert.equal(dayKindForDate(localAt(2026, 3, 7, 10)), "weekend"); // Saturday + assert.equal(classifyCircadian(localAt(2026, 3, 2, 9)).dayKind, "weekday"); + assert.equal(classifyCircadian(localAt(2026, 3, 1, 9)).dayKind, "weekend"); + }); + + it("uses non-moralizing tone hints", () => { + for (const band of ["morning", "afternoon", "evening", "night"] as const) { + const hint = toneHintForBand(band); + assert.ok(hint.length > 0); + assert.doesNotMatch(hint, /go to sleep|stop working|you should rest/i); + } + assert.match(toneHintForBand("morning"), /crisp|focused/i); + assert.match(toneHintForBand("night"), /calm|efficient/i); + }); +}); + +describe("prefs", () => { + const dir = mkdtempSync(join(tmpdir(), "agent-wallclock-prefs-")); + + after(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it("defaults circadian off when config.json is missing", () => { + assert.deepEqual(loadPrefs(dir), defaultPrefs()); + assert.equal(loadPrefs(dir).circadianEnabled, false); + }); + + it("round-trips circadianEnabled with restrictive perms", () => { + savePrefs({ circadianEnabled: true }, dir); + assert.equal(loadPrefs(dir).circadianEnabled, true); + if (process.platform !== "win32") { + const st = statSync(getPrefsFilePath(dir)); + assert.equal(st.mode & 0o777, 0o600); + } + savePrefs({ circadianEnabled: false }, dir); + assert.equal(loadPrefs(dir).circadianEnabled, false); + }); +}); + +describe("briefing circadian opt-in", () => { + const store = emptyStore(); + const morning = localAt(2026, 3, 2, 9, 30); // Monday morning + + it("omits Circadian when disabled (default)", () => { + const input = buildBriefingInput(store, { + nowDate: morning, + circadianEnabled: false, + }); + assert.equal(input.circadian, null); + + const text = renderBriefing(store, { + nowDate: morning, + circadianEnabled: false, + }); + assert.doesNotMatch(text, /## Circadian/); + + const compact = renderBriefingCompact(store, { + nowDate: morning, + circadianEnabled: false, + }); + assert.doesNotMatch(compact, /^circadian /m); + + assert.match(MODEL_RULES, /If a Circadian block is present/); + }); + + it("includes Circadian block when enabled", () => { + const input = buildBriefingInput(store, { + nowDate: morning, + circadianEnabled: true, + }); + assert.ok(input.circadian); + assert.equal(input.circadian?.band, "morning"); + assert.equal(input.circadian?.dayKind, "weekday"); + assert.equal(input.circadian?.localHour, 9); + + const text = renderBriefing(store, { + nowDate: morning, + circadianEnabled: true, + }); + assert.match(text, /## Circadian/); + assert.match(text, /Band: morning \(local hour 09\)/); + assert.match(text, /Day: weekday/); + assert.match(text, /Tone: /); + // Circadian appears after Now + const nowIdx = text.indexOf("## Now"); + const circIdx = text.indexOf("## Circadian"); + const sessionIdx = text.indexOf("## Active session"); + assert.ok(nowIdx >= 0 && circIdx > nowIdx && sessionIdx > circIdx); + + const compact = renderBriefingCompact(store, { + nowDate: morning, + circadianEnabled: true, + }); + assert.match(compact, /circadian morning weekday/); + }); + + it("loads prefs from storeDir when override omitted", () => { + const dir = mkdtempSync(join(tmpdir(), "agent-wallclock-brief-prefs-")); + try { + savePrefs({ circadianEnabled: true }, dir); + const input = buildBriefingInput(store, { + nowDate: morning, + storeDir: dir, + }); + assert.equal(input.circadian?.band, "morning"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/core/src/circadian.ts b/packages/core/src/circadian.ts new file mode 100644 index 0000000..6209395 --- /dev/null +++ b/packages/core/src/circadian.ts @@ -0,0 +1,44 @@ +import type { CircadianBand, CircadianContext, DayKind } from "./types.js"; + +const TONE_HINTS: Record = { + morning: + "Prefer crisp, focused replies. Do not assume fatigue or end-of-day wind-down.", + afternoon: + "Prefer steady, practical replies. Do not assume morning urgency or evening wind-down.", + evening: + "Prefer clear, concise replies. Do not assume the user is winding down or should stop.", + night: + "Prefer calm, efficient replies. Do not assume fatigue or tell the user to sleep.", +}; + +/** Fixed local-hour bands (v1; not user-tunable). */ +export function bandForLocalHour(hour: number): CircadianBand { + if (hour < 0 || hour > 23 || !Number.isInteger(hour)) { + throw new RangeError(`local hour must be an integer 0–23, got ${hour}`); + } + if (hour >= 5 && hour < 12) return "morning"; + if (hour >= 12 && hour < 17) return "afternoon"; + if (hour >= 17 && hour < 22) return "evening"; + return "night"; +} + +export function dayKindForDate(date: Date): DayKind { + const day = date.getDay(); // 0 Sun … 6 Sat + return day === 0 || day === 6 ? "weekend" : "weekday"; +} + +export function toneHintForBand(band: CircadianBand): string { + return TONE_HINTS[band]; +} + +export function classifyCircadian(nowDate: Date = new Date()): CircadianContext { + const localHour = nowDate.getHours(); + const band = bandForLocalHour(localHour); + const dayKind = dayKindForDate(nowDate); + return { + band, + dayKind, + localHour, + toneHint: toneHintForBand(band), + }; +} diff --git a/packages/core/src/effort-session.test.ts b/packages/core/src/effort-session.test.ts index 20c987d..0968b73 100644 --- a/packages/core/src/effort-session.test.ts +++ b/packages/core/src/effort-session.test.ts @@ -133,12 +133,17 @@ describe("effort and session", () => { it("briefing marks missing session as unknown and includes freshness", () => { const store = emptyStore(); - const input = buildBriefingInput(store, new Date("2026-03-01T09:30:00.000Z")); + const opts = { + nowDate: new Date("2026-03-01T09:30:00.000Z"), + circadianEnabled: false, + }; + const input = buildBriefingInput(store, opts); assert.equal(input.activeSession, null); assert.equal(input.sessionAgeMs, null); + assert.equal(input.circadian, null); assert.ok(input.generatedAt); - const text = renderBriefing(store, new Date("2026-03-01T09:30:00.000Z")); + const text = renderBriefing(store, opts); assert.match(text, /Local time: /); assert.match(text, /Age: unknown/); assert.match(text, /Never invent time of day/); @@ -146,9 +151,10 @@ describe("effort and session", () => { assert.match(text, /Generated at:/); assert.match(text, /Stale after:/); assert.match(text, /ISO \(UTC\)/); + assert.doesNotMatch(text, /## Circadian/); assert.ok(MODEL_RULES.includes("Trust only this Temporal Briefing")); - const compact = renderBriefingCompact(store, new Date("2026-03-01T09:30:00.000Z")); + const compact = renderBriefingCompact(store, opts); assert.match(compact, /iso\(UTC\)/); assert.match(compact, /session none/); }); @@ -163,6 +169,7 @@ describe("effort and session", () => { nowDate: t1, openSessionSoftCapMs: 8 * 60 * 60 * 1000, openSessionWarnAfterMs: 4 * 60 * 60 * 1000, + circadianEnabled: false, }); assert.match(text, /Warning:/); assert.match(text, /Soft cap:/); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 42a5e91..3774b80 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -4,6 +4,8 @@ export * from "./format.js"; export * from "./duration.js"; export * from "./config.js"; export * from "./store.js"; +export * from "./prefs.js"; +export * from "./circadian.js"; export * from "./effort.js"; export * from "./session.js"; export * from "./brief.js"; diff --git a/packages/core/src/prefs.ts b/packages/core/src/prefs.ts new file mode 100644 index 0000000..2e47eb1 --- /dev/null +++ b/packages/core/src/prefs.ts @@ -0,0 +1,61 @@ +import { + chmodSync, + existsSync, + readFileSync, + renameSync, + writeFileSync, +} from "node:fs"; +import { join } from "node:path"; +import { ensureStoreDir, getDefaultStoreDir } from "./store.js"; + +export const PREFS_FILENAME = "config.json"; + +const FILE_MODE = 0o600; + +export interface WallclockPrefs { + circadianEnabled: boolean; +} + +export function defaultPrefs(): WallclockPrefs { + return { circadianEnabled: false }; +} + +export function getPrefsFilePath(storeDir: string = getDefaultStoreDir()): string { + return join(storeDir, PREFS_FILENAME); +} + +export function loadPrefs(storeDir: string = getDefaultStoreDir()): WallclockPrefs { + const file = getPrefsFilePath(storeDir); + if (!existsSync(file)) { + return defaultPrefs(); + } + try { + const raw = JSON.parse(readFileSync(file, "utf8")) as Partial; + return { + circadianEnabled: Boolean(raw.circadianEnabled), + }; + } catch { + return defaultPrefs(); + } +} + +export function savePrefs( + prefs: WallclockPrefs, + storeDir: string = getDefaultStoreDir(), +): void { + ensureStoreDir(storeDir); + const file = getPrefsFilePath(storeDir); + const tmp = `${file}.tmp`; + const payload = `${JSON.stringify( + { circadianEnabled: Boolean(prefs.circadianEnabled) }, + null, + 2, + )}\n`; + writeFileSync(tmp, payload, { encoding: "utf8", mode: FILE_MODE }); + renameSync(tmp, file); + try { + chmodSync(file, FILE_MODE); + } catch { + // Best-effort on platforms that ignore mode bits. + } +} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index bd05847..54196ac 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -41,6 +41,18 @@ export interface NowInfo { epochMs: number; } +/** Local-hour circadian band (fixed boundaries; opt-in via prefs). */ +export type CircadianBand = "night" | "morning" | "afternoon" | "evening"; + +export type DayKind = "weekday" | "weekend"; + +export interface CircadianContext { + band: CircadianBand; + dayKind: DayKind; + localHour: number; + toneHint: string; +} + export interface BriefingInput { now: NowInfo; generatedAt: string; @@ -52,6 +64,8 @@ export interface BriefingInput { effortTotalMs: number | null; openSessionWarn: boolean; openSessionCapNote: boolean; + /** Present only when circadian prefs are enabled; otherwise null. */ + circadian: CircadianContext | null; } export interface BriefingOptions { @@ -59,4 +73,8 @@ export interface BriefingOptions { openSessionWarnAfterMs?: number; openSessionSoftCapMs?: number; nowDate?: Date; + /** Override store directory for prefs load (same as AGENT_WALLCLOCK_HOME). */ + storeDir?: string; + /** Override circadian toggle (tests); when omitted, load from config.json. */ + circadianEnabled?: boolean; } diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index 8fbfaa6..8bf62e6 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -80,7 +80,7 @@ server.tool( async () => { try { const store = loadStore(STORE_DIR); - return textResult(renderBriefing(store)); + return textResult(renderBriefing(store, { storeDir: STORE_DIR })); } catch (err) { return errorResult(err); }