diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 25a8e43..da518eb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,4 +18,3 @@ jobs: - run: npm run build - run: npm test - run: npm run smoke - - run: node scripts/mcp-smoke.mjs diff --git a/README.md b/README.md index 0da938e..4ebebd9 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # Agent Wallclock -**Stop language models from inventing the time.** +**Stop language models from inventing the time — when a fresh Temporal Briefing or MCP tools are attached.** Agent Wallclock is a small **local** tool. It reads your real clock and a local effort ledger, then produces a **Temporal Briefing**. You feed that briefing into Claude, ChatGPT, Cursor, or any other model — via paste, custom instructions, or MCP tools. -This is not a hosted product and not a chat UI. It is plumbing so *any* model can stop saying “go to sleep” at 9:30am or “you’ve been at this for days” twelve minutes in. +Soft prompt adapters alone do **not** enforce anything. ChatGPT/Claude web only work if you paste a **fresh** briefing. This is not a hosted product and not a chat UI. --- @@ -19,11 +19,11 @@ This is not a hosted product and not a chat UI. It is plumbing so *any* model ca ``` 1. **CLI / MCP** on your machine measure real time and logged effort. -2. They emit a **Temporal Briefing** (markdown). +2. They emit a **Temporal Briefing** (markdown) with a **Generated at** timestamp. 3. The model only “knows” time if you **give it that briefing** (or it calls MCP `get_briefing`). -4. Host adapters (custom instructions / skills) tell the model: **trust the briefing; never invent durations.** +4. Host adapters tell the model: **trust a fresh briefing; never invent durations; refresh if stale.** -No cloud sync. No account. The model never sees your store unless you paste a briefing or enable local MCP. +No cloud sync. No account. The Wallclock process itself makes no network calls. Pasting a briefing **does** upload that time data to the host. --- @@ -34,8 +34,9 @@ git clone https://github.com/TelivityAI/agent-wallclock.git cd agent-wallclock npm install && npm run build -# optional alias -alias wallclock='node '"$PWD"'/packages/cli/dist/bin.js' +# put `wallclock` on PATH for this machine +npm link -w @agent-wallclock/cli +# or one-off: npm exec -w @agent-wallclock/cli -- wallclock ... wallclock init wallclock effort start auth-rewrite @@ -44,35 +45,38 @@ wallclock brief # print wallclock brief --copy # copy to clipboard when available ``` -Example output: +Example output (real CLI shape; durations use `d`/`h`/`m`/`s`): -![Terminal showing wallclock brief](docs/images/01-cli-brief.png) +![Terminal showing wallclock brief (captured from real CLI)](docs/images/01-cli-brief.png) ```markdown # Temporal Briefing (Agent Wallclock) +## Freshness +- Generated at: 2026-07-31T07:30:22.605Z +- Stale after: 15m — refresh with `wallclock brief` or MCP `get_briefing` before time-based advice + ## Now -- Local date: 2026-07-30 -- Local time: 09:30:12 -- Weekday: Thursday +- Local date: 2026-07-31 +- Local time: 02:30:22 +- Weekday: Friday - Timezone: America/Chicago (Central Daylight Time, UTC-05:00) -- ISO (UTC): ... +- ISO (UTC): 2026-07-31T07:30:22.605Z ## Active session - Status: open -- Age: 12m +- Started: 2026-07-31T07:18:22.580Z +- Age: 12m (720025 ms) ## Active effort - Name: auth-rewrite -- Calendar age: 3w 2d -- Logged work time: 14h 20m +- Calendar age: 23d +- Logged work time: 14h 20m (includes open session if any) ... ``` That block is the whole product surface. Everything below is **how each host receives it**. -*(Images below are illustrative walkthroughs of the real CLI / host setup flow.)* - --- ## ChatGPT — how it gets the data @@ -82,16 +86,16 @@ ChatGPT cannot read your disk. You give it two things: | Piece | What you do | |-------|-------------| | Standing rules | Paste [`adapters/chatgpt-custom-instructions.md`](adapters/chatgpt-custom-instructions.md) into **Customize ChatGPT → Custom instructions** | -| Live clock | At the start of a session (or when time matters), paste `wallclock brief` into the chat | +| Live clock | At the start of a session (or when time matters), paste a **fresh** `wallclock brief` into the chat | -![ChatGPT custom instructions + pasted briefing](docs/images/02-chatgpt-setup.png) +![MOCK: ChatGPT custom instructions + pasted briefing (illustrative UI)](docs/images/02-chatgpt-setup.png) **Checklist** 1. Open ChatGPT → profile → **Customize ChatGPT**. 2. Put the adapter text in custom instructions. 3. Run `wallclock brief --copy` on your machine. -4. Paste into the chat before asking anything time-sensitive. +4. Paste into the chat before asking anything time-sensitive. Refresh if older than ~15 minutes. 5. Ask: “What time is it for me, and how long have I been on auth-rewrite?” — it should quote the briefing, not invent numbers. --- @@ -103,19 +107,18 @@ Two paths (pick one or both): ### A) Paste (works everywhere: claude.ai, Projects, API) 1. Add [`adapters/claude-project-instructions.md`](adapters/claude-project-instructions.md) to a **Project**’s instructions (or custom instructions). -2. Paste `wallclock brief` into the chat when you start work. +2. Paste a fresh `wallclock brief` into the chat when you start work. -![Claude chat using a Temporal Briefing](docs/images/03-claude-chat.png) +![MOCK: Claude chat using a Temporal Briefing (illustrative UI)](docs/images/03-claude-chat.png) ### B) MCP (Claude Desktop) 1. `npm install && npm run build` in this repo. -2. Copy [`adapters/mcp/claude-desktop.json`](adapters/mcp/claude-desktop.json) into Claude Desktop MCP config. -3. Replace `/ABSOLUTE/PATH/TO/agent-wallclock` with your clone path. -4. Restart Claude Desktop. -5. Ask Claude to call **`get_briefing`** before time-based advice. +2. Run `wallclock mcp-config --print claude` and merge the JSON into Claude Desktop MCP config (or edit [`adapters/mcp/claude-desktop.json`](adapters/mcp/claude-desktop.json)). +3. Restart Claude Desktop. +4. Ask Claude to call **`get_briefing`** before time-based advice. -Claude then pulls the same local store the CLI uses — still on your machine, still no upload from the Wallclock process itself. +Claude then pulls the same local store the CLI uses — still on your machine. Write tools stay off unless `AGENT_WALLCLOCK_WRITES=1`. --- @@ -125,13 +128,13 @@ Three paths (combine freely): | Path | What | |------|------| -| Terminal | Agent runs `wallclock brief` (or you paste it) | +| Terminal | Agent runs absolute `node …/packages/cli/dist/bin.js brief` (or you paste). Do not assume `wallclock` is on PATH. | | Skill / rule | Copy [`adapters/cursor-skill/`](adapters/cursor-skill/) into your skills dir; add the rule fragment from `rule.md` | -| MCP | Add [`adapters/mcp/cursor-mcp.json`](adapters/mcp/cursor-mcp.json), point `args` at `packages/mcp/dist/server.js`, enable the server | +| MCP | Run `wallclock mcp-config --print cursor` (or edit [`adapters/mcp/cursor-mcp.json`](adapters/mcp/cursor-mcp.json)), enable the server | -![Cursor MCP with agent-wallclock tools](docs/images/04-cursor-mcp.png) +![MOCK: Cursor MCP with agent-wallclock tools (illustrative UI)](docs/images/04-cursor-mcp.png) -**MCP tools:** `get_now`, `get_briefing`, `list_efforts`, `start_effort`, `log_session`. +**MCP tools:** `get_now`, `get_briefing`, `list_efforts` (read). `start_effort`, `log_session` require `AGENT_WALLCLOCK_WRITES=1`. When the skill/rule is on, Cursor should call `get_briefing` (or run the CLI) instead of guessing “it’s late” or “you’ve been grinding for days.” @@ -143,7 +146,7 @@ When the skill/rule is on, Cursor should call `get_briefing` (or run the CLI) in |-------------------|------------------------| | “It’s late — you should sleep.” (it’s 9:30am) | “Local time is 09:30 Thursday.” | | “You’ve been at this for days.” (12 minutes) | “This session is 12 minutes old.” | -| “New chat = new project.” | “Effort `auth-rewrite` has 14h logged over ~3 weeks.” | +| “New chat = new project.” | “Effort `auth-rewrite` has 14h 20m logged over ~23 days.” | --- @@ -154,11 +157,26 @@ git clone https://github.com/TelivityAI/agent-wallclock.git cd agent-wallclock npm install npm run build + +# recommended: link the CLI onto PATH +npm link -w @agent-wallclock/cli +wallclock --help + +# one-off without linking +npm exec -w @agent-wallclock/cli -- wallclock --help +# or node packages/cli/dist/bin.js --help ``` Requirements: Node.js 20+. +Filled MCP config with absolute server path: + +```bash +wallclock mcp-config --print cursor +wallclock mcp-config --print claude +``` + --- ## Commands @@ -167,20 +185,22 @@ Requirements: Node.js 20+. |---------|---------| | `wallclock now` | Local date, time, timezone, weekday, ISO | | `wallclock brief` | Full Temporal Briefing (`--copy` when supported) | -| `wallclock effort start\|list\|status\|log` | Named efforts + cumulative time | +| `wallclock effort start\|list\|status\|log` | Named efforts + cumulative time (names are slug-normalized) | | `wallclock session start\|end` | Open/close a work block on an effort | -| `wallclock timeline` | Recent sessions | +| `wallclock timeline` | Recent sessions (open rows show live age) | +| `wallclock mcp-config --print ` | Emit filled MCP JSON | | `wallclock init` | Create `~/.agent-wallclock/` and point at adapters | -Override store directory: `AGENT_WALLCLOCK_HOME=/path node packages/cli/dist/bin.js ...` +Override store directory: `AGENT_WALLCLOCK_HOME=/path wallclock ...` --- ## Privacy -- State lives only under `~/.agent-wallclock/` (JSON). +- State lives only under `~/.agent-wallclock/` (JSON; directory `0700`, file `0600` when the OS allows). - CLI and MCP make **no network calls**. -- A model sees time data only if **you** paste a briefing or enable local MCP in that host. +- A model sees time data only if **you** paste a briefing or enable local MCP in that host — pasting **does** send that data to the host cloud. +- MCP mutations default **off** (`AGENT_WALLCLOCK_WRITES=1` to enable). --- @@ -193,7 +213,7 @@ Override store directory: `AGENT_WALLCLOCK_HOME=/path node packages/cli/dist/bin | `packages/mcp` | Local stdio MCP server | | `adapters/` | Copy-paste instructions per host | | `catalog/models.md` | Attach points cheat sheet | -| `docs/images/` | Demo screenshots used above | +| `docs/images/` | Demo screenshots (`01` real CLI; `02`–`04` labeled MOCK) | --- diff --git a/adapters/chatgpt-custom-instructions.md b/adapters/chatgpt-custom-instructions.md index dcff39b..a7d4643 100644 --- a/adapters/chatgpt-custom-instructions.md +++ b/adapters/chatgpt-custom-instructions.md @@ -1,6 +1,6 @@ # ChatGPT custom instructions (Agent Wallclock) -Add the block below under **Customize ChatGPT → Custom instructions** (what ChatGPT should know / how it should respond). Before important sessions, paste a fresh `wallclock brief` into the chat. +Add the block below under **Customize ChatGPT → Custom instructions** (what ChatGPT should know / how it should respond). Before important sessions, paste a **fresh** `wallclock brief` into the chat. --- @@ -8,9 +8,12 @@ You have access to Agent Wallclock temporal context when the user pastes a Tempo Rules: - Trust only that briefing for local time, timezone, session age, and effort logged time. +- Check **Generated at** / **Stale after**. If the briefing is older than its 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. - 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. + +Privacy note: pasting a briefing uploads that time data to the ChatGPT host. diff --git a/adapters/claude-project-instructions.md b/adapters/claude-project-instructions.md index 2448c01..98674e5 100644 --- a/adapters/claude-project-instructions.md +++ b/adapters/claude-project-instructions.md @@ -1,6 +1,6 @@ # Claude Project / custom instructions (Agent Wallclock) -Add this to a Claude Project’s instructions, or to custom instructions / memory for Claude.ai / Claude Desktop. Prefer MCP `get_briefing` when the Agent Wallclock MCP server is configured; otherwise paste `wallclock brief`. +Add this to a Claude Project’s instructions, or to custom instructions / memory for Claude.ai / Claude Desktop. Prefer MCP `get_briefing` when the Agent Wallclock MCP server is configured; otherwise paste a **fresh** `wallclock brief`. --- @@ -9,9 +9,12 @@ Add this to a Claude Project’s instructions, or to custom instructions / memor When a Temporal Briefing is available (pasted or via MCP tools `get_now` / `get_briefing`): - Use it as the only source for wall-clock time, session age, and effort duration. +- Check **Generated at** / **Stale after**. If older than the 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. - 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. If tools are available, call `get_briefing` before making time-sensitive statements. + +Privacy note: pasting a briefing (or using a cloud host with MCP) shares that time data with the host. diff --git a/adapters/cursor-skill/SKILL.md b/adapters/cursor-skill/SKILL.md index 2dcebc1..c1ff7b2 100644 --- a/adapters/cursor-skill/SKILL.md +++ b/adapters/cursor-skill/SKILL.md @@ -10,19 +10,23 @@ description: >- ## Instructions -1. Before making claims about local time, session length, or effort duration, obtain a Temporal Briefing: +1. Before making claims about local time, session length, or effort duration, obtain a **fresh** Temporal Briefing: - Prefer MCP tool `get_briefing` when the Agent Wallclock MCP server is configured. - - Otherwise run `wallclock brief` in the terminal (or ask the user to paste it). -2. Treat the briefing as authoritative. Do not invent: + - Otherwise ask the user to paste `wallclock brief`, or run the CLI via an absolute path: + `node /ABSOLUTE/PATH/TO/agent-wallclock/packages/cli/dist/bin.js brief` + (or `npm exec -w @agent-wallclock/cli -- wallclock brief` from the clone). + - Do not assume `wallclock` is on PATH. +2. Check **Generated at** / **Stale after**. If older than the freshness window (default 15 minutes), refresh before time-based advice. +3. Treat the briefing as authoritative. Do not invent: - time of day / “it’s late” - session age (“hours”, “days”) - effort history on multi-week work -3. If briefing data is missing, say **unknown** — never guess from priors. -4. Do not advise sleep or “you’ve done enough” based on invented duration. -5. Optional tools: `get_now`, `list_efforts`, `start_effort`, `log_session`. +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. Optional read tools: `get_now`, `list_efforts`. Write tools (`start_effort`, `log_session`) require `AGENT_WALLCLOCK_WRITES=1`. ## Rule fragment (user rules) ``` -Temporal context: Use Agent Wallclock. Never invent clock time, session age, or effort duration. Prefer get_briefing / wallclock brief; missing fields are unknown. +Temporal context: Use Agent Wallclock. Never invent clock time, session age, or effort duration. Prefer get_briefing or a fresh wallclock brief; refresh if stale; missing fields are unknown. ``` diff --git a/adapters/cursor-skill/rule.md b/adapters/cursor-skill/rule.md index 7ef93a8..a02ad38 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. Prefer MCP `get_briefing` or CLI `wallclock brief`. 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. Prefer MCP `get_briefing` or a fresh CLI briefing. Refresh if **Generated at** is outside the freshness window. If fields are missing, say unknown. Do not invent sleep/rest advice from guessed time. Do not assume `wallclock` is on PATH. diff --git a/adapters/generic-system-prompt.md b/adapters/generic-system-prompt.md index 3773c84..adc79cc 100644 --- a/adapters/generic-system-prompt.md +++ b/adapters/generic-system-prompt.md @@ -1,15 +1,16 @@ # Agent Wallclock — generic system prompt -Paste this into any model’s system prompt or custom instructions. When a Temporal Briefing is present in the conversation (from `wallclock brief` or MCP `get_briefing`), treat it as authoritative. +Paste this into any model’s system prompt or custom instructions. When a Temporal Briefing is present in the conversation (from `wallclock brief` or MCP `get_briefing`), treat it as authoritative **only while fresh**. ## Temporal rules 1. Use the Temporal Briefing for local date, local time, timezone, session age, and effort duration. -2. Never invent the time of day, how long the user has been working in this chat, or how long a project has been underway. -3. If briefing fields are missing, say **unknown** or ask — do not guess from training priors. -4. 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. -5. Do not equate a new chat with a new project when an active effort shows multi-day or multi-week logged work. -6. Prefer precise durations from the briefing (e.g. “12 minutes”) over vague claims (“hours”, “days”) when numbers are available. +2. Check **Generated at** / **Stale after**. If the briefing is older than its 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. ## When no briefing is available diff --git a/adapters/mcp/README.md b/adapters/mcp/README.md index 0126d54..33549bf 100644 --- a/adapters/mcp/README.md +++ b/adapters/mcp/README.md @@ -1,10 +1,18 @@ # MCP configuration 1. Build the repo (`npm install && npm run build`). -2. Copy `claude-desktop.json` or `cursor-mcp.json` into the host MCP config. -3. Replace `/ABSOLUTE/PATH/TO/agent-wallclock` with the real clone path on the machine. +2. Prefer generating a filled config (absolute server path included): + +```bash +node packages/cli/dist/bin.js mcp-config --print cursor +node packages/cli/dist/bin.js mcp-config --print claude +``` + +3. Or copy `claude-desktop.json` / `cursor-mcp.json` and replace `/ABSOLUTE/PATH/TO/agent-wallclock` with your clone path. 4. Restart the host. +**Writes default off.** Read tools (`get_now`, `get_briefing`, `list_efforts`) always work. Mutating tools (`start_effort`, `log_session`) require `AGENT_WALLCLOCK_WRITES=1` in the server `env` block. + Optional: set `AGENT_WALLCLOCK_HOME` in the server `env` block to point at a non-default store directory. The server is stdio-only and performs no network I/O. diff --git a/adapters/mcp/claude-desktop.json b/adapters/mcp/claude-desktop.json index 180078b..7f1b72d 100644 --- a/adapters/mcp/claude-desktop.json +++ b/adapters/mcp/claude-desktop.json @@ -4,7 +4,10 @@ "command": "node", "args": [ "/ABSOLUTE/PATH/TO/agent-wallclock/packages/mcp/dist/server.js" - ] + ], + "env": { + "AGENT_WALLCLOCK_WRITES": "0" + } } } } diff --git a/adapters/mcp/cursor-mcp.json b/adapters/mcp/cursor-mcp.json index 180078b..7f1b72d 100644 --- a/adapters/mcp/cursor-mcp.json +++ b/adapters/mcp/cursor-mcp.json @@ -4,7 +4,10 @@ "command": "node", "args": [ "/ABSOLUTE/PATH/TO/agent-wallclock/packages/mcp/dist/server.js" - ] + ], + "env": { + "AGENT_WALLCLOCK_WRITES": "0" + } } } } diff --git a/catalog/models.md b/catalog/models.md index d63b2ee..330e777 100644 --- a/catalog/models.md +++ b/catalog/models.md @@ -2,24 +2,27 @@ How to attach Agent Wallclock temporal context on common hosts. +Models only stop inventing time **when a fresh briefing or MCP tools are attached**. Soft prompt adapters do not enforce anything by themselves. + ## ChatGPT (web / app) - **Custom instructions**: paste [`adapters/chatgpt-custom-instructions.md`](../adapters/chatgpt-custom-instructions.md). -- **Per chat**: paste output of `wallclock brief` at the start of a session or when duration matters. +- **Per chat**: paste output of `wallclock brief` at the start of a session or when duration matters. Refresh if older than ~15 minutes. - MCP is not assumed; briefing paste is the portable path. +- Pasting a briefing uploads that time data to the host. ## Claude (claude.ai / Projects) - **Project instructions**: paste [`adapters/claude-project-instructions.md`](../adapters/claude-project-instructions.md). -- **Claude Desktop + MCP**: add [`adapters/mcp/claude-desktop.json`](../adapters/mcp/claude-desktop.json) and use `get_briefing`. -- Fallback: paste `wallclock brief`. +- **Claude Desktop + MCP**: run `wallclock mcp-config --print claude` (or edit [`adapters/mcp/claude-desktop.json`](../adapters/mcp/claude-desktop.json)) and use `get_briefing`. +- Fallback: paste a fresh `wallclock brief`. ## Cursor - **Skill**: copy [`adapters/cursor-skill/`](../adapters/cursor-skill/) into a personal or project skills directory. - **User rule**: add the fragment from [`adapters/cursor-skill/rule.md`](../adapters/cursor-skill/rule.md). -- **MCP**: add [`adapters/mcp/cursor-mcp.json`](../adapters/mcp/cursor-mcp.json). -- Agents can also run `wallclock brief` in the terminal. +- **MCP**: run `wallclock mcp-config --print cursor` (or edit [`adapters/mcp/cursor-mcp.json`](../adapters/mcp/cursor-mcp.json)). +- Agents can run the CLI via absolute `node …/packages/cli/dist/bin.js brief` — do not assume `wallclock` is on PATH. ## Generic API / other hosts diff --git a/docs/images/01-cli-brief.png b/docs/images/01-cli-brief.png index 8200114..2b98ef7 100644 Binary files a/docs/images/01-cli-brief.png and b/docs/images/01-cli-brief.png differ diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts index 90db4b5..4de77cb 100644 --- a/packages/cli/src/bin.ts +++ b/packages/cli/src/bin.ts @@ -1,12 +1,12 @@ #!/usr/bin/env node import { readFileSync, existsSync } from "node:fs"; -import { dirname, join } from "node:path"; +import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { getNow, initStore, loadStore, - saveStore, + updateStore, getDefaultStoreDir, startEffort, listEfforts, @@ -17,11 +17,12 @@ import { timeline, renderBriefing, formatDuration, + parseDuration, } from "@agent-wallclock/core"; import { copyToClipboard } from "./copy.js"; -import { parseDurationArg } from "./parse.js"; const STORE_DIR = process.env.AGENT_WALLCLOCK_HOME?.trim() || getDefaultStoreDir(); +const HERE = dirname(fileURLToPath(import.meta.url)); function printHelp(): void { console.log(`Agent Wallclock — local temporal context for language models @@ -32,24 +33,29 @@ Usage: wallclock effort start wallclock effort list wallclock effort status [name] - wallclock effort log # e.g. 30m, 2h + wallclock effort log # duration is the last token (e.g. 30m) wallclock session start [effort] wallclock session end wallclock timeline [limit] + wallclock mcp-config --print wallclock init +Install (from repo root after npm install && npm run build): + npm link -w @agent-wallclock/cli + # or: npm exec -w @agent-wallclock/cli -- wallclock ... + Environment: - AGENT_WALLCLOCK_HOME Override store directory (default: ~/.agent-wallclock) + AGENT_WALLCLOCK_HOME Override store directory (default: ~/.agent-wallclock) + AGENT_WALLCLOCK_WRITES=1 Enable MCP write tools (start_effort, log_session) -Privacy: store is local JSON only. No network calls. +Privacy: store is local JSON only. No network calls from this CLI. `); } function adaptersRoot(): string | null { - const here = dirname(fileURLToPath(import.meta.url)); const candidates = [ - join(here, "../../../adapters"), - join(here, "../../adapters"), + join(HERE, "../../../adapters"), + join(HERE, "../../adapters"), join(process.cwd(), "adapters"), ]; for (const c of candidates) { @@ -58,6 +64,17 @@ function adaptersRoot(): string | null { return null; } +function mcpServerPath(): string { + const candidates = [ + resolve(HERE, "../../mcp/dist/server.js"), + resolve(HERE, "../../../packages/mcp/dist/server.js"), + ]; + for (const c of candidates) { + if (existsSync(c)) return c; + } + return candidates[0]!; +} + function cmdNow(): void { const now = getNow(); console.log(`Local date: ${now.localDate}`); @@ -93,13 +110,20 @@ function cmdEffort(args: string[]): void { if (sub === "start") { const name = args.slice(1).join(" ").trim(); if (!name) throw new Error("Usage: wallclock effort start "); - let store = loadStore(STORE_DIR); - const result = startEffort(store, name); - saveStore(result.store, STORE_DIR); + let created = false; + let effortName = ""; + let effortId = ""; + updateStore(STORE_DIR, (store) => { + const result = startEffort(store, name); + created = result.created; + effortName = result.effort.name; + effortId = result.effort.id; + return result.store; + }); console.log( - result.created - ? `Started effort "${result.effort.name}" (${result.effort.id})` - : `Using existing effort "${result.effort.name}" (${result.effort.id})`, + created + ? `Started effort "${effortName}" (${effortId})` + : `Using existing effort "${effortName}" (${effortId})`, ); return; } @@ -113,8 +137,9 @@ function cmdEffort(args: string[]): void { } for (const e of efforts) { const active = store.activeEffortId === e.id ? " [active]" : ""; + const status = effortStatus(store, e.id); console.log( - `${e.name}${active} logged=${formatDuration(e.totalMs)} sessions=${e.sessionCount} started=${e.startedAt}`, + `${e.name}${active} logged=${formatDuration(status.totalMs)} sessions=${e.sessionCount} started=${e.startedAt}`, ); } return; @@ -132,22 +157,30 @@ function cmdEffort(args: string[]): void { console.log(`Active: ${status.isActive ? "yes" : "no"}`); console.log(`Started: ${status.effort.startedAt}`); console.log(`Calendar age: ${formatDuration(status.ageMs)}`); - console.log(`Logged work: ${formatDuration(status.totalMs)}`); + console.log(`Logged work: ${formatDuration(status.totalMs)} (includes open session if any)`); console.log(`Sessions: ${status.effort.sessionCount}`); console.log(`Last activity: ${status.effort.lastActivityAt ?? "unknown"}`); return; } if (sub === "log") { - const name = args[1]; - const dur = args[2]; - if (!name || !dur) throw new Error("Usage: wallclock effort log "); - let store = loadStore(STORE_DIR); - const ms = parseDurationArg(dur); - const result = logManualDuration(store, name, ms); - saveStore(result.store, STORE_DIR); + if (args.length < 3) { + throw new Error("Usage: wallclock effort log "); + } + const dur = args[args.length - 1]!; + const name = args.slice(1, -1).join(" ").trim(); + if (!name) throw new Error("Usage: wallclock effort log "); + const ms = parseDuration(dur); + let effortName = ""; + let totalMs = 0; + updateStore(STORE_DIR, (store) => { + const result = logManualDuration(store, name, ms); + effortName = result.effort.name; + totalMs = result.effort.totalMs; + return result.store; + }); console.log( - `Logged ${formatDuration(ms)} on "${result.effort.name}". Total=${formatDuration(result.effort.totalMs)}`, + `Logged ${formatDuration(ms)} on "${effortName}". Total=${formatDuration(totalMs)}`, ); return; } @@ -159,18 +192,30 @@ function cmdSession(args: string[]): void { const sub = args[0]; if (sub === "start") { const effort = args.slice(1).join(" ").trim() || undefined; - let store = loadStore(STORE_DIR); - const result = startSession(store, effort); - saveStore(result.store, STORE_DIR); - console.log(`Session open on "${result.effort.name}" (${result.session.id})`); + let effortName = ""; + let sessionId = ""; + updateStore(STORE_DIR, (store) => { + const result = startSession(store, effort); + effortName = result.effort.name; + sessionId = result.session.id; + return result.store; + }); + console.log(`Session open on "${effortName}" (${sessionId})`); return; } if (sub === "end") { - let store = loadStore(STORE_DIR); - const result = endSession(store); - saveStore(result.store, STORE_DIR); + let durationMs = 0; + let effortName = ""; + let totalMs = 0; + updateStore(STORE_DIR, (store) => { + const result = endSession(store); + durationMs = result.durationMs; + effortName = result.effort.name; + totalMs = result.effort.totalMs; + return result.store; + }); console.log( - `Session closed. Duration=${formatDuration(result.durationMs)}. Effort "${result.effort.name}" total=${formatDuration(result.effort.totalMs)}`, + `Session closed. Duration=${formatDuration(durationMs)}. Effort "${effortName}" total=${formatDuration(totalMs)}`, ); return; } @@ -189,13 +234,45 @@ function cmdTimeline(args: string[]): void { return; } for (const row of rows) { - const dur = row.durationMs != null ? formatDuration(row.durationMs) : "open/unknown"; + const dur = row.durationMs != null ? formatDuration(row.durationMs) : "unknown"; console.log( `${row.startedAt} ${row.effortName} ${dur} ${row.endedAt ? "closed" : "open"}`, ); } } +function cmdMcpConfig(args: string[]): void { + const print = args.includes("--print"); + const host = args.find((a) => a === "claude" || a === "cursor"); + if (!print || !host) { + throw new Error("Usage: wallclock mcp-config --print "); + } + const server = mcpServerPath(); + if (!existsSync(server)) { + throw new Error( + `MCP server build not found at ${server}. Run \`npm run build\` from the repo root first.`, + ); + } + + const config = { + mcpServers: { + "agent-wallclock": { + command: "node", + args: [server], + env: { + AGENT_WALLCLOCK_WRITES: "0", + }, + }, + }, + }; + + console.log(JSON.stringify(config, null, 2)); + console.error(""); + console.error(`# Host: ${host}`); + console.error(`# Server: ${server}`); + console.error("# Writes default off. Set AGENT_WALLCLOCK_WRITES=1 in env to enable mutations."); +} + function cmdInit(): void { const store = initStore(STORE_DIR); console.log(`Store ready at ${STORE_DIR}`); @@ -205,6 +282,7 @@ function cmdInit(): void { console.log(" 1. wallclock effort start "); console.log(" 2. wallclock session start"); console.log(" 3. wallclock brief"); + console.log(" 4. wallclock mcp-config --print cursor # filled MCP JSON"); console.log(""); const root = adaptersRoot(); if (root) { @@ -256,6 +334,9 @@ function main(argv: string[]): void { case "timeline": cmdTimeline(rest); break; + case "mcp-config": + cmdMcpConfig(rest); + break; case "init": cmdInit(); break; diff --git a/packages/cli/src/parse.ts b/packages/cli/src/parse.ts index 48f8b21..c0a0aea 100644 --- a/packages/cli/src/parse.ts +++ b/packages/cli/src/parse.ts @@ -1,20 +1,2 @@ -export function parseDurationArg(raw: string): number { - const trimmed = raw.trim().toLowerCase(); - const match = trimmed.match(/^(\d+(?:\.\d+)?)(ms|s|m|h|d)$/); - if (!match) { - throw new Error(`Invalid duration "${raw}". Use forms like 30m, 2h, 90s, 1d.`); - } - const value = Number(match[1]); - const unit = match[2]; - const mult = - unit === "ms" - ? 1 - : unit === "s" - ? 1000 - : unit === "m" - ? 60_000 - : unit === "h" - ? 3_600_000 - : 86_400_000; - return Math.round(value * mult); -} +/** @deprecated Import parseDuration from @agent-wallclock/core instead. */ +export { parseDuration as parseDurationArg } from "@agent-wallclock/core"; diff --git a/packages/core/src/brief.ts b/packages/core/src/brief.ts index e8c6d5d..6aa5768 100644 --- a/packages/core/src/brief.ts +++ b/packages/core/src/brief.ts @@ -3,11 +3,18 @@ import { ageMs, formatDuration } from "./format.js"; import { getActiveSession } from "./session.js"; import type { BriefingInput, StoreData } from "./types.js"; +/** Briefings older than this should be refreshed before time-based advice. */ +export const BRIEFING_STALE_AFTER_MS = 15 * 60 * 1000; + +/** Open sessions older than this get a warning in the briefing. */ +export const OPEN_SESSION_WARN_AFTER_MS = 4 * 60 * 60 * 1000; + export const MODEL_RULES = [ "Trust only this Temporal Briefing for clock, session age, and effort duration.", "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 Generated-at is older than the stated freshness window, ask for a refreshed briefing before time-based claims.", ].join(" "); export function buildBriefingInput( @@ -39,11 +46,15 @@ export function buildBriefingInput( return { now, + generatedAt: now.iso, + staleAfterMs: BRIEFING_STALE_AFTER_MS, activeEffort, activeSession, sessionAgeMs, effortAgeMs, effortTotalMs, + openSessionWarn: + sessionAgeMs != null && sessionAgeMs >= OPEN_SESSION_WARN_AFTER_MS, }; } @@ -52,6 +63,10 @@ export function renderBriefing(store: StoreData, nowDate: Date = new Date()): st const lines: string[] = [ "# Temporal Briefing (Agent Wallclock)", "", + "## Freshness", + `- Generated at: ${input.generatedAt}`, + `- Stale after: ${formatDuration(input.staleAfterMs)} — refresh with \`wallclock brief\` or MCP \`get_briefing\` before time-based advice`, + "", "## Now", `- Local date: ${input.now.localDate}`, `- Local time: ${input.now.localTime}`, @@ -66,6 +81,11 @@ export function renderBriefing(store: StoreData, nowDate: Date = new Date()): st lines.push(`- Status: open`); lines.push(`- Started: ${input.activeSession.startedAt}`); lines.push(`- Age: ${formatDuration(input.sessionAgeMs)} (${input.sessionAgeMs} ms)`); + if (input.openSessionWarn) { + lines.push( + `- Warning: open session older than ${formatDuration(OPEN_SESSION_WARN_AFTER_MS)} — confirm it is still intentional, or run \`wallclock session end\``, + ); + } } else { lines.push(`- Status: none`); lines.push(`- Age: unknown`); diff --git a/packages/core/src/duration.ts b/packages/core/src/duration.ts new file mode 100644 index 0000000..b2f5e3b --- /dev/null +++ b/packages/core/src/duration.ts @@ -0,0 +1,23 @@ +/** + * Parse a simple duration token such as 30m, 2h, 90s, 1d. + */ +export function parseDuration(raw: string): number { + const trimmed = raw.trim().toLowerCase(); + const match = trimmed.match(/^(\d+(?:\.\d+)?)(ms|s|m|h|d)$/); + if (!match) { + throw new Error(`Invalid duration "${raw}". Use forms like 30m, 2h, 90s, 1d.`); + } + const value = Number(match[1]); + const unit = match[2]; + const mult = + unit === "ms" + ? 1 + : unit === "s" + ? 1000 + : unit === "m" + ? 60_000 + : unit === "h" + ? 3_600_000 + : 86_400_000; + return Math.round(value * mult); +} diff --git a/packages/core/src/effort-session.test.ts b/packages/core/src/effort-session.test.ts index 229f926..c0fbecb 100644 --- a/packages/core/src/effort-session.test.ts +++ b/packages/core/src/effort-session.test.ts @@ -1,8 +1,8 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { emptyStore } from "./store.js"; -import { startEffort, effortStatus } from "./effort.js"; -import { startSession, endSession } from "./session.js"; +import { startEffort, effortStatus, findEffort } from "./effort.js"; +import { startSession, endSession, timeline } from "./session.js"; import { buildBriefingInput, renderBriefing } from "./brief.js"; describe("effort and session", () => { @@ -25,16 +25,50 @@ describe("effort and session", () => { assert.ok(status.ageMs != null && status.ageMs >= 30 * 60 * 1000); }); - it("briefing marks missing session as unknown and includes model rules", () => { + it("treats Auth Rewrite and auth-rewrite as the same effort", () => { + let store = emptyStore(); + ({ store } = startEffort(store, "Auth Rewrite", new Date("2026-03-01T10:00:00.000Z"))); + const again = startEffort(store, "auth-rewrite", new Date("2026-03-01T11:00:00.000Z")); + assert.equal(again.created, false); + assert.equal(again.effort.name, "auth-rewrite"); + assert.equal(store.efforts.length, 1); + assert.ok(findEffort(again.store, "AUTH rewrite")); + }); + + it("includes open session age in effort status total", () => { + let store = emptyStore(); + const t0 = new Date("2026-03-01T10:00:00.000Z"); + const t1 = new Date("2026-03-01T10:12:00.000Z"); + ({ store } = startEffort(store, "auth-rewrite", t0)); + ({ store } = startSession(store, undefined, t0)); + const status = effortStatus(store, "auth-rewrite", t1); + assert.equal(status.totalMs, 12 * 60 * 1000); + }); + + it("timeline reports live age for open sessions", () => { + let store = emptyStore(); + const t0 = new Date("2026-03-01T10:00:00.000Z"); + const t1 = new Date("2026-03-01T10:05:00.000Z"); + ({ store } = startEffort(store, "auth-rewrite", t0)); + ({ store } = startSession(store, undefined, t0)); + const rows = timeline(store, 5, t1); + assert.equal(rows[0]?.durationMs, 5 * 60 * 1000); + assert.equal(rows[0]?.endedAt, null); + }); + + 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")); assert.equal(input.activeSession, null); assert.equal(input.sessionAgeMs, null); + assert.ok(input.generatedAt); const text = renderBriefing(store, new Date("2026-03-01T09:30:00.000Z")); assert.match(text, /Local time: /); assert.match(text, /Age: unknown/); assert.match(text, /Never invent time of day/); assert.match(text, /09:30:00/); + assert.match(text, /Generated at:/); + assert.match(text, /Stale after:/); }); }); diff --git a/packages/core/src/effort.ts b/packages/core/src/effort.ts index 064fd1c..3b16fdd 100644 --- a/packages/core/src/effort.ts +++ b/packages/core/src/effort.ts @@ -2,16 +2,26 @@ import { ageMs } from "./format.js"; import { newId } from "./ids.js"; import type { Effort, StoreData } from "./types.js"; -function normalizeName(name: string): string { - return name.trim().toLowerCase(); +/** + * Normalize an effort name for matching: lowercase, non-alnum → hyphens. + * "Auth Rewrite" and "auth-rewrite" resolve to the same effort. + */ +export function slugifyEffortName(name: string): string { + return name + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); } export function findEffort(store: StoreData, nameOrId: string): Effort | undefined { const key = nameOrId.trim(); + if (!key) return undefined; const byId = store.efforts.find((e) => e.id === key); if (byId) return byId; - const norm = normalizeName(key); - return store.efforts.find((e) => normalizeName(e.name) === norm); + const slug = slugifyEffortName(key); + if (!slug) return undefined; + return store.efforts.find((e) => slugifyEffortName(e.name) === slug); } export function startEffort( @@ -19,12 +29,12 @@ export function startEffort( name: string, now: Date = new Date(), ): { store: StoreData; effort: Effort; created: boolean } { - const trimmed = name.trim(); - if (!trimmed) { + const slug = slugifyEffortName(name); + if (!slug) { throw new Error("Effort name is required"); } - const existing = findEffort(store, trimmed); + const existing = findEffort(store, slug); if (existing) { const next: StoreData = { ...store, @@ -35,7 +45,7 @@ export function startEffort( const effort: Effort = { id: newId("eff"), - name: trimmed, + name: slug, startedAt: now.toISOString(), totalMs: 0, sessionCount: 0, @@ -80,10 +90,22 @@ export function effortStatus( } const age = ageMs(effort.startedAt, now); + let totalMs = effort.totalMs; + const open = + store.activeSessionId != null + ? store.sessions.find((s) => s.id === store.activeSessionId) + : undefined; + if (open && open.effortId === effort.id) { + const openAge = ageMs(open.startedAt, now); + if (Number.isFinite(openAge)) { + totalMs += openAge; + } + } + return { effort, ageMs: Number.isFinite(age) ? age : null, - totalMs: effort.totalMs, + totalMs, isActive: store.activeEffortId === effort.id, }; } diff --git a/packages/core/src/format.test.ts b/packages/core/src/format.test.ts index c9bdf82..54f08df 100644 --- a/packages/core/src/format.test.ts +++ b/packages/core/src/format.test.ts @@ -1,6 +1,8 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { formatDuration } from "./format.js"; +import { parseDuration } from "./duration.js"; +import { slugifyEffortName } from "./effort.js"; describe("formatDuration", () => { it("returns unknown for missing values", () => { @@ -16,3 +18,24 @@ describe("formatDuration", () => { assert.equal(formatDuration(90_000_000), "1d 1h"); }); }); + +describe("parseDuration", () => { + it("parses common tokens", () => { + assert.equal(parseDuration("30m"), 30 * 60_000); + assert.equal(parseDuration("2h"), 2 * 3_600_000); + assert.equal(parseDuration("90s"), 90_000); + assert.equal(parseDuration("1d"), 86_400_000); + }); + + it("rejects invalid input", () => { + assert.throws(() => parseDuration("abc"), /Invalid duration/); + }); +}); + +describe("slugifyEffortName", () => { + it("collapses spaced and hyphenated names", () => { + assert.equal(slugifyEffortName("Auth Rewrite"), "auth-rewrite"); + assert.equal(slugifyEffortName("auth-rewrite"), "auth-rewrite"); + assert.equal(slugifyEffortName(" AUTH rewrite "), "auth-rewrite"); + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4f5250b..0ac8c8d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,6 +1,7 @@ export * from "./types.js"; export * from "./clock.js"; export * from "./format.js"; +export * from "./duration.js"; export * from "./store.js"; export * from "./effort.js"; export * from "./session.js"; diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 2825373..d119554 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -101,6 +101,7 @@ export function endSession( export function timeline( store: StoreData, limit = 20, + now: Date = new Date(), ): Array<{ kind: "session"; id: string; @@ -116,12 +117,16 @@ export function timeline( .sort((a, b) => Date.parse(b.startedAt) - Date.parse(a.startedAt)) .slice(0, limit) .map((s) => { - const end = s.endedAt ? Date.parse(s.endedAt) : null; const start = Date.parse(s.startedAt); - const durationMs = - end != null && Number.isFinite(end) && Number.isFinite(start) - ? Math.max(0, end - start) - : null; + let durationMs: number | null = null; + if (s.endedAt) { + const end = Date.parse(s.endedAt); + if (Number.isFinite(end) && Number.isFinite(start)) { + durationMs = Math.max(0, end - start); + } + } else if (Number.isFinite(start)) { + durationMs = Math.max(0, now.getTime() - start); + } return { kind: "session" as const, id: s.id, diff --git a/packages/core/src/store.test.ts b/packages/core/src/store.test.ts index 4c8a61f..68e3cfe 100644 --- a/packages/core/src/store.test.ts +++ b/packages/core/src/store.test.ts @@ -1,9 +1,9 @@ import assert from "node:assert/strict"; -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { after, describe, it } from "node:test"; -import { initStore, loadStore, saveStore, emptyStore } from "./store.js"; +import { initStore, loadStore, saveStore, emptyStore, getStoreFilePath } from "./store.js"; import { startEffort } from "./effort.js"; describe("store", () => { @@ -26,4 +26,22 @@ describe("store", () => { assert.equal(loaded.efforts.length, 1); assert.equal(loaded.efforts[0]?.name, "auth-rewrite"); }); + + it("sets restrictive file permissions when supported", () => { + if (process.platform === "win32") return; + const file = getStoreFilePath(dir); + const st = statSync(file); + assert.equal(st.mode & 0o777, 0o600); + }); + + it("gives a friendly error for corrupt JSON", () => { + const badDir = mkdtempSync(join(tmpdir(), "agent-wallclock-bad-")); + try { + initStore(badDir); + writeFileSync(getStoreFilePath(badDir), "{not-json", "utf8"); + assert.throws(() => loadStore(badDir), /Corrupt Agent Wallclock store/); + } finally { + rmSync(badDir, { recursive: true, force: true }); + } + }); }); diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 53dcc96..620b174 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -1,10 +1,24 @@ -import { mkdirSync, readFileSync, renameSync, writeFileSync, existsSync } from "node:fs"; +import { + chmodSync, + closeSync, + existsSync, + mkdirSync, + openSync, + readFileSync, + renameSync, + unlinkSync, + writeFileSync, +} from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import type { StoreData } from "./types.js"; export const STORE_DIRNAME = ".agent-wallclock"; export const STORE_FILENAME = "store.json"; +export const STORE_LOCK_FILENAME = "store.lock"; + +const DIR_MODE = 0o700; +const FILE_MODE = 0o600; export function emptyStore(): StoreData { return { @@ -24,29 +38,137 @@ export function getStoreFilePath(storeDir: string = getDefaultStoreDir()): strin return join(storeDir, STORE_FILENAME); } +export function getStoreLockPath(storeDir: string = getDefaultStoreDir()): string { + return join(storeDir, STORE_LOCK_FILENAME); +} + export function ensureStoreDir(storeDir: string = getDefaultStoreDir()): string { - mkdirSync(storeDir, { recursive: true }); + mkdirSync(storeDir, { recursive: true, mode: DIR_MODE }); + try { + chmodSync(storeDir, DIR_MODE); + } catch { + // Best-effort on platforms that ignore mode bits. + } return storeDir; } +function isProcessAlive(pid: number): boolean { + if (!Number.isFinite(pid) || pid <= 0) return false; + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +/** + * Best-effort exclusive lock around store mutations. + * Stale locks (dead pid) are cleared. If a live lock cannot be acquired + * after a short wait, the callback still runs (documented last-write-wins fallback). + */ +export function withStoreLock(storeDir: string, fn: () => T): T { + ensureStoreDir(storeDir); + const lockPath = getStoreLockPath(storeDir); + const deadline = Date.now() + 1000; + let fd: number | null = null; + let acquired = false; + + while (!acquired && Date.now() < deadline) { + try { + fd = openSync(lockPath, "wx"); + writeFileSync(fd, `${process.pid}\n`, { encoding: "utf8" }); + acquired = true; + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code !== "EEXIST") { + // Locking unsupported or unexpected — proceed unlocked. + break; + } + try { + const raw = readFileSync(lockPath, "utf8").trim(); + const pid = Number(raw); + if (!isProcessAlive(pid)) { + unlinkSync(lockPath); + continue; + } + } catch { + try { + unlinkSync(lockPath); + } catch { + // ignore + } + continue; + } + const waitUntil = Date.now() + 25; + while (Date.now() < waitUntil) { + // brief spin while another process holds the lock + } + } + } + + try { + return fn(); + } finally { + if (fd != null) { + try { + closeSync(fd); + } catch { + // ignore + } + try { + unlinkSync(lockPath); + } catch { + // ignore + } + } + } +} + export function loadStore(storeDir: string = getDefaultStoreDir()): StoreData { const file = getStoreFilePath(storeDir); if (!existsSync(file)) { return emptyStore(); } - const raw = readFileSync(file, "utf8"); - const parsed = JSON.parse(raw) as StoreData; - if (parsed.version !== 1 || !Array.isArray(parsed.efforts) || !Array.isArray(parsed.sessions)) { - throw new Error(`Invalid Agent Wallclock store at ${file}`); + let raw: string; + try { + raw = readFileSync(file, "utf8"); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new Error( + `Could not read Agent Wallclock store at ${file}: ${message}`, + ); + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new Error( + `Corrupt Agent Wallclock store at ${file}. Fix the JSON or delete the file, then run \`wallclock init\`.`, + ); + } + + if ( + typeof parsed !== "object" || + parsed === null || + (parsed as StoreData).version !== 1 || + !Array.isArray((parsed as StoreData).efforts) || + !Array.isArray((parsed as StoreData).sessions) + ) { + throw new Error( + `Invalid Agent Wallclock store at ${file} (expected version 1 with efforts/sessions arrays). Fix or delete the file, then run \`wallclock init\`.`, + ); } + const data = parsed as StoreData; return { version: 1, - efforts: parsed.efforts, - sessions: parsed.sessions, - activeSessionId: parsed.activeSessionId ?? null, - activeEffortId: parsed.activeEffortId ?? null, + efforts: data.efforts, + sessions: data.sessions, + activeSessionId: data.activeSessionId ?? null, + activeEffortId: data.activeEffortId ?? null, }; } @@ -54,17 +176,41 @@ export function saveStore(data: StoreData, storeDir: string = getDefaultStoreDir ensureStoreDir(storeDir); const file = getStoreFilePath(storeDir); const tmp = `${file}.tmp`; - writeFileSync(tmp, `${JSON.stringify(data, null, 2)}\n`, "utf8"); + writeFileSync(tmp, `${JSON.stringify(data, null, 2)}\n`, { + encoding: "utf8", + mode: FILE_MODE, + }); renameSync(tmp, file); + try { + chmodSync(file, FILE_MODE); + } catch { + // Best-effort. + } +} + +/** + * Load → mutate → save under a best-effort store lock. + */ +export function updateStore( + storeDir: string, + updater: (store: StoreData) => StoreData, +): StoreData { + return withStoreLock(storeDir, () => { + const next = updater(loadStore(storeDir)); + saveStore(next, storeDir); + return next; + }); } export function initStore(storeDir: string = getDefaultStoreDir()): StoreData { - ensureStoreDir(storeDir); - const file = getStoreFilePath(storeDir); - if (existsSync(file)) { - return loadStore(storeDir); - } - const data = emptyStore(); - saveStore(data, storeDir); - return data; + return withStoreLock(storeDir, () => { + ensureStoreDir(storeDir); + const file = getStoreFilePath(storeDir); + if (existsSync(file)) { + return loadStore(storeDir); + } + const data = emptyStore(); + saveStore(data, storeDir); + return data; + }); } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 44ac7b0..2ee1933 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -34,9 +34,12 @@ export interface NowInfo { export interface BriefingInput { now: NowInfo; + generatedAt: string; + staleAfterMs: number; activeEffort: Effort | null; activeSession: Session | null; sessionAgeMs: number | null; effortAgeMs: number | null; effortTotalMs: number | null; + openSessionWarn: boolean; } diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index 666a90f..b6c2877 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -8,7 +8,7 @@ import { getDefaultStoreDir, getNow, loadStore, - saveStore, + updateStore, startEffort, listEfforts, startSession, @@ -16,9 +16,12 @@ import { renderBriefing, formatDuration, logManualDuration, + parseDuration, + effortStatus, } from "@agent-wallclock/core"; const STORE_DIR = process.env.AGENT_WALLCLOCK_HOME?.trim() || getDefaultStoreDir(); +const WRITES_ENABLED = process.env.AGENT_WALLCLOCK_WRITES === "1"; function textResult(text: string) { return { @@ -26,6 +29,14 @@ function textResult(text: string) { }; } +function requireWrites(): void { + if (!WRITES_ENABLED) { + throw new Error( + "MCP write tools are disabled. Set AGENT_WALLCLOCK_WRITES=1 to enable start_effort and log_session.", + ); + } +} + const server = new McpServer({ name: "agent-wallclock", version: "0.1.0", @@ -51,7 +62,7 @@ server.tool( server.tool( "get_briefing", - "Return a Temporal Briefing with now, active session age, and active effort logged time. Use this instead of inventing durations.", + "Return a Temporal Briefing with generated-at freshness, now, active session age, and active effort logged time. Refresh if stale. Use this instead of inventing durations.", {}, async () => { const store = loadStore(STORE_DIR); @@ -71,7 +82,8 @@ server.tool( } const lines = efforts.map((e) => { const active = store.activeEffortId === e.id ? "active" : "inactive"; - return `${e.name} | ${active} | logged=${formatDuration(e.totalMs)} | sessions=${e.sessionCount} | started=${e.startedAt}`; + const status = effortStatus(store, e.id); + return `${e.name} | ${active} | logged=${formatDuration(status.totalMs)} | sessions=${e.sessionCount} | started=${e.startedAt}`; }); return textResult(lines.join("\n")); }, @@ -79,23 +91,31 @@ server.tool( server.tool( "start_effort", - "Create or select a named effort in the local store and make it active.", + "Create or select a named effort in the local store and make it active. Requires AGENT_WALLCLOCK_WRITES=1.", { name: z.string().min(1).describe("Effort name, e.g. auth-rewrite") }, async ({ name }) => { - let store = loadStore(STORE_DIR); - const result = startEffort(store, name); - saveStore(result.store, STORE_DIR); + requireWrites(); + let created = false; + let effortName = ""; + let effortId = ""; + updateStore(STORE_DIR, (store) => { + const result = startEffort(store, name); + created = result.created; + effortName = result.effort.name; + effortId = result.effort.id; + return result.store; + }); return textResult( - result.created - ? `Created effort "${result.effort.name}" (${result.effort.id})` - : `Selected existing effort "${result.effort.name}" (${result.effort.id})`, + created + ? `Created effort "${effortName}" (${effortId})` + : `Selected existing effort "${effortName}" (${effortId})`, ); }, ); server.tool( "log_session", - "Manage work sessions. action=start opens a session on an effort; action=end closes the open session and adds its duration to the effort; action=manual adds a duration without an open session.", + "Manage work sessions. action=start opens a session on an effort; action=end closes the open session and adds its duration to the effort; action=manual adds a duration without an open session. Requires AGENT_WALLCLOCK_WRITES=1.", { action: z.enum(["start", "end", "manual"]), effort: z.string().optional().describe("Effort name or id (for start/manual)"), @@ -105,46 +125,48 @@ server.tool( .describe("For manual only: duration like 30m, 2h, 90s"), }, async ({ action, effort, duration }) => { - let store = loadStore(STORE_DIR); + requireWrites(); if (action === "start") { - const result = startSession(store, effort); - saveStore(result.store, STORE_DIR); - return textResult(`Session open on "${result.effort.name}" (${result.session.id})`); + let effortName = ""; + let sessionId = ""; + updateStore(STORE_DIR, (store) => { + const result = startSession(store, effort); + effortName = result.effort.name; + sessionId = result.session.id; + return result.store; + }); + return textResult(`Session open on "${effortName}" (${sessionId})`); } if (action === "end") { - const result = endSession(store); - saveStore(result.store, STORE_DIR); + let durationMs = 0; + let totalMs = 0; + updateStore(STORE_DIR, (store) => { + const result = endSession(store); + durationMs = result.durationMs; + totalMs = result.effort.totalMs; + return result.store; + }); return textResult( - `Session closed. Duration=${formatDuration(result.durationMs)}. Effort total=${formatDuration(result.effort.totalMs)}`, + `Session closed. Duration=${formatDuration(durationMs)}. Effort total=${formatDuration(totalMs)}`, ); } if (!effort || !duration) { throw new Error("manual log_session requires effort and duration (e.g. 30m)"); } - const match = duration.trim().toLowerCase().match(/^(\d+(?:\.\d+)?)(ms|s|m|h|d)$/); - if (!match) { - throw new Error(`Invalid duration "${duration}". Use 30m, 2h, 90s, or 1d.`); - } - const value = Number(match[1]); - const unit = match[2]; - const mult = - unit === "ms" - ? 1 - : unit === "s" - ? 1000 - : unit === "m" - ? 60_000 - : unit === "h" - ? 3_600_000 - : 86_400_000; - const ms = Math.round(value * mult); - const result = logManualDuration(store, effort, ms); - saveStore(result.store, STORE_DIR); + const ms = parseDuration(duration); + let effortName = ""; + let totalMs = 0; + updateStore(STORE_DIR, (store) => { + const result = logManualDuration(store, effort, ms); + effortName = result.effort.name; + totalMs = result.effort.totalMs; + return result.store; + }); return textResult( - `Logged ${formatDuration(ms)} on "${result.effort.name}". Total=${formatDuration(result.effort.totalMs)}`, + `Logged ${formatDuration(ms)} on "${effortName}". Total=${formatDuration(totalMs)}`, ); }, ); diff --git a/scripts/mcp-smoke.mjs b/scripts/mcp-smoke.mjs index 29e6262..0a27fb8 100644 --- a/scripts/mcp-smoke.mjs +++ b/scripts/mcp-smoke.mjs @@ -1,6 +1,8 @@ -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdtempSync, rmSync, existsSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; import { getDefaultStoreDir, initStore, @@ -15,9 +17,155 @@ import { logManualDuration, } from "../packages/core/dist/index.js"; +const root = fileURLToPath(new URL("..", import.meta.url)); const home = mkdtempSync(join(tmpdir(), "wallclock-mcp-smoke-")); process.env.AGENT_WALLCLOCK_HOME = home; +function send(proc, msg) { + proc.stdin.write(`${JSON.stringify(msg)}\n`); +} + +function readMessages(proc, count, timeoutMs = 8000) { + return new Promise((resolve, reject) => { + const messages = []; + let buffer = ""; + const timer = setTimeout(() => { + cleanup(); + reject(new Error(`timed out waiting for ${count} MCP messages; got ${messages.length}`)); + }, timeoutMs); + + function onData(chunk) { + buffer += chunk.toString("utf8"); + let idx; + while ((idx = buffer.indexOf("\n")) >= 0) { + const line = buffer.slice(0, idx).trim(); + buffer = buffer.slice(idx + 1); + if (!line) continue; + try { + messages.push(JSON.parse(line)); + } catch (err) { + cleanup(); + reject(new Error(`invalid JSON from MCP: ${line}`)); + return; + } + if (messages.length >= count) { + cleanup(); + resolve(messages); + return; + } + } + } + + function cleanup() { + clearTimeout(timer); + proc.stdout.off("data", onData); + } + + proc.stdout.on("data", onData); + }); +} + +async function mcpProtocolSmoke() { + const serverPath = join(root, "packages/mcp/dist/server.js"); + if (!existsSync(serverPath)) { + throw new Error("MCP server build missing"); + } + + const proc = spawn(process.execPath, [serverPath], { + env: { + ...process.env, + AGENT_WALLCLOCK_HOME: home, + // writes off by default — exercise read tools + AGENT_WALLCLOCK_WRITES: "0", + }, + stdio: ["pipe", "pipe", "pipe"], + }); + + try { + send(proc, { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2024-11-05", + capabilities: {}, + clientInfo: { name: "wallclock-mcp-smoke", version: "0.1.0" }, + }, + }); + const [initResp] = await readMessages(proc, 1); + if (initResp.error) throw new Error(`initialize failed: ${JSON.stringify(initResp.error)}`); + if (!initResp.result?.serverInfo?.name) { + throw new Error("initialize missing serverInfo"); + } + + send(proc, { + jsonrpc: "2.0", + method: "notifications/initialized", + }); + + send(proc, { + jsonrpc: "2.0", + id: 2, + method: "tools/list", + params: {}, + }); + const [toolsResp] = await readMessages(proc, 1); + const names = (toolsResp.result?.tools ?? []).map((t) => t.name); + for (const required of ["get_now", "get_briefing", "list_efforts", "start_effort", "log_session"]) { + if (!names.includes(required)) { + throw new Error(`tools/list missing ${required}`); + } + } + + send(proc, { + jsonrpc: "2.0", + id: 3, + method: "tools/call", + params: { name: "get_now", arguments: {} }, + }); + const [nowResp] = await readMessages(proc, 1); + const nowText = nowResp.result?.content?.[0]?.text ?? ""; + if (!nowText.includes("local_date=")) { + throw new Error(`get_now unexpected: ${nowText}`); + } + + send(proc, { + jsonrpc: "2.0", + id: 4, + method: "tools/call", + params: { name: "get_briefing", arguments: {} }, + }); + const [briefResp] = await readMessages(proc, 1); + const briefText = briefResp.result?.content?.[0]?.text ?? ""; + if (!briefText.includes("Temporal Briefing") || !briefText.includes("Generated at:")) { + throw new Error(`get_briefing unexpected: ${briefText.slice(0, 200)}`); + } + + send(proc, { + jsonrpc: "2.0", + id: 5, + method: "tools/call", + params: { name: "start_effort", arguments: { name: "should-fail" } }, + }); + const [writeResp] = await readMessages(proc, 1); + const writeText = + writeResp.result?.content?.[0]?.text ?? + writeResp.error?.message ?? + JSON.stringify(writeResp); + if (!/AGENT_WALLCLOCK_WRITES|disabled|writes/i.test(writeText)) { + // MCP SDK may wrap tool errors as isError content + const isError = writeResp.result?.isError; + if (!isError && !/AGENT_WALLCLOCK_WRITES|disabled|writes/i.test(JSON.stringify(writeResp))) { + throw new Error(`expected writes-disabled error, got: ${writeText}`); + } + } + + console.log("MCP stdio protocol smoke OK"); + } finally { + proc.kill("SIGTERM"); + } +} + try { initStore(home); let store = loadStore(home); @@ -28,6 +176,7 @@ try { const briefing = renderBriefing(store); if (!briefing.includes("docs-pass")) throw new Error("briefing missing effort"); + if (!briefing.includes("Generated at:")) throw new Error("briefing missing freshness"); if (!getNow().localDate) throw new Error("get_now failed"); if (listEfforts(store).length !== 1) throw new Error("list_efforts failed"); @@ -38,7 +187,6 @@ try { ({ store } = logManualDuration(store, "docs-pass", 60_000)); saveStore(store, home); - const { existsSync } = await import("node:fs"); const serverPath = new URL("../packages/mcp/dist/server.js", import.meta.url); if (!existsSync(serverPath)) { throw new Error("MCP server build missing"); @@ -49,6 +197,8 @@ try { console.log("MCP/core tool-path smoke OK"); console.log(`default store helper: ${getDefaultStoreDir() ? "ok" : "missing"}`); + + await mcpProtocolSmoke(); } finally { rmSync(home, { recursive: true, force: true }); } diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs index 1a4b57c..29b510c 100644 --- a/scripts/smoke.mjs +++ b/scripts/smoke.mjs @@ -8,11 +8,17 @@ const root = fileURLToPath(new URL("..", import.meta.url)); const bin = join(root, "packages/cli/dist/bin.js"); const home = mkdtempSync(join(tmpdir(), "wallclock-smoke-")); -function run(args) { +function run(args, { expectFail = false } = {}) { const result = spawnSync(process.execPath, [bin, ...args], { encoding: "utf8", env: { ...process.env, AGENT_WALLCLOCK_HOME: home }, }); + if (expectFail) { + if (result.status === 0) { + throw new Error(`expected failure for wallclock ${args.join(" ")}`); + } + return result.stderr || result.stdout; + } if (result.status !== 0) { throw new Error( `wallclock ${args.join(" ")} failed:\n${result.stdout}\n${result.stderr}`, @@ -23,7 +29,12 @@ function run(args) { try { run(["init"]); - run(["effort", "start", "auth-rewrite"]); + run(["effort", "start", "Auth", "Rewrite"]); + const again = run(["effort", "start", "auth-rewrite"]); + if (!again.includes("Using existing")) { + throw new Error("expected slug match for Auth Rewrite / auth-rewrite"); + } + run(["effort", "log", "auth", "rewrite", "30m"]); run(["session", "start"]); const brief = run(["brief"]); if (!brief.includes("Temporal Briefing")) { @@ -32,6 +43,13 @@ try { if (!brief.includes("auth-rewrite")) { throw new Error("brief missing active effort"); } + if (!brief.includes("Generated at:")) { + throw new Error("brief missing freshness"); + } + const cfg = run(["mcp-config", "--print", "cursor"]); + if (!cfg.includes("mcpServers") || !cfg.includes("server.js")) { + throw new Error("mcp-config missing server path"); + } run(["session", "end"]); run(["timeline"]); run(["now"]);