From 50af5c6be5b7eaa5bee82465ec5d552d07e671fd Mon Sep 17 00:00:00 2001 From: TelivityAI Date: Fri, 31 Jul 2026 02:49:12 -0500 Subject: [PATCH 1/2] Harden Agent Wallclock across CLI, MCP, store, docs, and CI. Implements the pass-hardening-100 roadmap: richer CLI/MCP surfaces, store lock/repair/backup, expanded tests, honest MOCK docs, packaging dry-run, and release hygiene without network telemetry. --- .github/CODEOWNERS | 14 + .github/ISSUE_TEMPLATE/bug_report.md | 38 ++ .github/ISSUE_TEMPLATE/feature_request.md | 26 ++ .github/PULL_REQUEST_TEMPLATE.md | 32 ++ .github/dependabot.yml | 18 + .github/release.yml | 24 ++ .github/workflows/ci.yml | 98 ++++- .github/workflows/release-tag.yml | 32 ++ .gitignore | 3 + CHANGELOG.md | 36 ++ CONTRIBUTING.md | 76 ++++ NOTICE | 32 ++ README.md | 141 +++--- SECURITY.md | 50 +++ TROUBLESHOOTING.md | 128 ++++++ adapters/chatgpt-custom-instructions.md | 2 +- adapters/claude-project-instructions.md | 8 +- adapters/cursor-skill/SKILL.md | 26 +- adapters/cursor-skill/rule.md | 2 +- adapters/generic-system-prompt.md | 6 +- adapters/mcp/README.md | 47 +- catalog/models.md | 60 ++- docs/NO_LEAK_CHECKLIST.md | 45 ++ docs/PASS_PR_NAMING.md | 45 ++ docs/RELEASE_NOTES_TEMPLATE.md | 59 +++ docs/SHIP_CHECKLIST.md | 53 +++ docs/architecture.md | 104 +++++ docs/npm-scope.md | 50 +++ package-lock.json | 13 +- package.json | 7 +- packages/cli/.npmignore | 4 + packages/cli/README.md | 49 +++ packages/cli/package.json | 13 +- packages/cli/src/bin.test.ts | 60 +++ packages/cli/src/bin.ts | 498 +++++++++++++++++++--- packages/cli/src/copy.ts | 11 + packages/core/.npmignore | 4 + packages/core/README.md | 36 ++ packages/core/package.json | 11 +- packages/core/src/brief.ts | 79 +++- packages/core/src/clock.test.ts | 38 ++ packages/core/src/clock.ts | 21 +- packages/core/src/config.ts | 58 +++ packages/core/src/doctor.ts | 105 +++++ packages/core/src/effort-session.test.ts | 110 ++++- packages/core/src/effort.ts | 142 +++++- packages/core/src/errors.ts | 51 +++ packages/core/src/format.test.ts | 36 +- packages/core/src/format.ts | 16 +- packages/core/src/index.ts | 3 + packages/core/src/session.ts | 77 +++- packages/core/src/store.test.ts | 89 +++- packages/core/src/store.ts | 266 ++++++++++-- packages/core/src/types.ts | 19 +- packages/mcp/.npmignore | 4 + packages/mcp/README.md | 61 +++ packages/mcp/package.json | 12 +- packages/mcp/src/server.ts | 249 +++++++---- scripts/leak-grep.mjs | 59 +++ scripts/mcp-smoke.mjs | 125 +++--- scripts/perf-brief.mjs | 48 +++ scripts/publish-dry-run.mjs | 23 + scripts/qa-local.mjs | 28 ++ scripts/smoke.mjs | 29 +- 64 files changed, 3345 insertions(+), 364 deletions(-) create mode 100644 .github/CODEOWNERS create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/dependabot.yml create mode 100644 .github/release.yml create mode 100644 .github/workflows/release-tag.yml create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 NOTICE create mode 100644 SECURITY.md create mode 100644 TROUBLESHOOTING.md create mode 100644 docs/NO_LEAK_CHECKLIST.md create mode 100644 docs/PASS_PR_NAMING.md create mode 100644 docs/RELEASE_NOTES_TEMPLATE.md create mode 100644 docs/SHIP_CHECKLIST.md create mode 100644 docs/architecture.md create mode 100644 docs/npm-scope.md create mode 100644 packages/cli/.npmignore create mode 100644 packages/cli/README.md create mode 100644 packages/cli/src/bin.test.ts create mode 100644 packages/core/.npmignore create mode 100644 packages/core/README.md create mode 100644 packages/core/src/clock.test.ts create mode 100644 packages/core/src/config.ts create mode 100644 packages/core/src/doctor.ts create mode 100644 packages/core/src/errors.ts create mode 100644 packages/mcp/.npmignore create mode 100644 packages/mcp/README.md create mode 100644 scripts/leak-grep.mjs create mode 100644 scripts/perf-brief.mjs create mode 100644 scripts/publish-dry-run.mjs create mode 100644 scripts/qa-local.mjs diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..7f061ea --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,14 @@ +# CODEOWNERS +# +# TelivityAI maintainers review changes to core packages, MCP surface, +# adapters, and release docs. Update @TelivityAI to the actual GitHub team +# or maintainer handles when known. + +* @TelivityAI + +/packages/core/ @TelivityAI +/packages/cli/ @TelivityAI +/packages/mcp/ @TelivityAI +/adapters/ @TelivityAI +/docs/ @TelivityAI +/.github/ @TelivityAI diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..b16935d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,38 @@ +--- +name: Bug report +about: Report a problem with Agent Wallclock CLI, store, or MCP server +title: "[bug] " +labels: bug +assignees: '' +--- + +## Version + +- Agent Wallclock version: (`wallclock --version`) +- Node version: +- OS: + +## What happened + + + +## Steps to reproduce + +1. +2. +3. + +## Expected behavior + +## Actual behavior + +## Diagnostics + +```text +# Paste sanitized output — no home paths, no store.json contents unless intentional +wallclock doctor +``` + +## Additional context + + diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..d916e97 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,26 @@ +--- +name: Feature request +about: Suggest an idea for Agent Wallclock +title: "[feature] " +labels: enhancement +assignees: '' +--- + +## Problem + + + +## Proposed solution + +## Alternatives considered + +## Host impact + +- [ ] CLI only +- [ ] MCP tools +- [ ] Adapters / MODEL_RULES +- [ ] Docs + +## Additional context + + diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..05abad7 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,32 @@ +## Summary + + + +## Type + +- [ ] fix/pass batch (`fix/pass-NNN-slug`) +- [ ] Feature (`feat/…`) +- [ ] Docs only +- [ ] Chore / deps + +## Test plan + +- [ ] `npm run build` +- [ ] `npm test` +- [ ] `npm run smoke` +- [ ] `wallclock doctor` (if CLI/MCP touched) +- [ ] Adapters updated if `MODEL_RULES` or briefing shape changed + +## No-leak + +- [ ] No home paths (`/Users/…`) or tokens in diff +- [ ] MCP paths use placeholders or `mcp-config --print` output redacted + +## Screenshots / docs + +- [ ] MOCK images still labeled MOCK +- [ ] No fake product UI presented as real + +## Related + + diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..6f6e166 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,18 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: "/" + schedule: + interval: weekly + groups: + production-dependencies: + dependency-type: production + development-dependencies: + dependency-type: development + open-pull-requests-limit: 10 + + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 5 diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 0000000..c0aab4e --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,24 @@ +changelog: + exclude: + labels: + - dependencies + - skip-changelog + categories: + - title: Added + labels: + - enhancement + - feature + - title: Changed + labels: + - changed + - title: Fixed + labels: + - bug + - fix + - title: Documentation + labels: + - documentation + - title: Maintenance + labels: + - chore + - internal diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index da518eb..a23b797 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,7 +6,7 @@ on: pull_request: jobs: - test: + lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -15,6 +15,102 @@ jobs: node-version: "22" cache: npm - run: npm install + - run: npm run lint + + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node-version: [20, 22] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: npm + - run: npm install - run: npm run build - run: npm test + + coverage: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + - run: npm install + - run: npm run test:coverage -w @agent-wallclock/core + + smoke: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + - run: npm install - run: npm run smoke + + smoke-windows: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + - run: npm install + - run: npm run build + - run: node scripts/smoke.mjs + + smoke-macos: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + - run: npm install + - run: npm run build + - run: node scripts/smoke.mjs + - run: node scripts/mcp-smoke.mjs + + security: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + - run: npm install + - run: node scripts/leak-grep.mjs + - name: no outbound network calls in package sources + run: | + set -euo pipefail + if grep -RInE 'fetch\(|http\.request|https\.request|axios|got\(' packages/*/src; then + echo "Found outbound network usage in packages/*/src" + exit 1 + fi + echo "no-telemetry OK" + - run: npm audit --audit-level=high + - name: gitleaks + uses: gitleaks/gitleaks-action@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + actionlint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: actionlint + run: | + bash <(curl -sSf https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash) + ./actionlint -color diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml new file mode 100644 index 0000000..39eae56 --- /dev/null +++ b/.github/workflows/release-tag.yml @@ -0,0 +1,32 @@ +name: release-tag + +on: + workflow_dispatch: + inputs: + version: + description: "Annotated tag to create (e.g. v0.1.1)" + required: true + type: string + +permissions: + contents: write + +jobs: + tag: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Validate tag format + run: | + if ! echo "${{ inputs.version }}" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "Tag must match vMAJOR.MINOR.PATCH (e.g. v0.1.1)" + exit 1 + fi + - name: Create annotated tag + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git tag -a "${{ inputs.version }}" -m "Release ${{ inputs.version }}" + git push origin "${{ inputs.version }}" diff --git a/.gitignore b/.gitignore index a8c5ed1..9e74a7e 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,6 @@ coverage/ *.tgz .turbo/ *.tsbuildinfo +store.backup.*.json +*.corrupt.* +**/store.backup.*.json diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..cb7b911 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,36 @@ +# Changelog + +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. + +## [0.1.0] — 2026-07-31 + +First post-QA hardening release batch (`fix/pass-hardening-100`). + +### Added + +- **CLI:** `wallclock doctor`, `wallclock where`, `wallclock --version`, shell `completion` +- **CLI:** `wallclock brief --json` and `--compact` +- **CLI:** `wallclock store backup` / `store restore` +- **CLI:** `wallclock session status` +- **CLI:** `wallclock effort rename`, `archive`, `unarchive`, `delete --confirm` +- **CLI:** `wallclock mcp-config --print vscode` (alongside claude/cursor) +- **MCP:** `get_session_status`, `get_timeline` read tools +- **MCP:** `log_session` with `start` / `end` / `manual` actions (writes gated) +- **Core:** Store v2 migration path, lock handling, `runDoctor` checks +- **Docs:** Architecture, pass PR naming, no-leak and ship checklists, troubleshooting, contributing, security policy +- **GitHub:** PR/issue templates, Dependabot, CODEOWNERS, release notes template + +### Changed + +- Briefing includes explicit **Freshness** block (`Generated at`, `Stale after`) +- `MODEL_RULES` synced across adapters (freshness TTL language, no invented durations) +- README honest about paste/MCP trust boundaries and MOCK illustrative screenshots +- MCP write tools remain opt-in via `AGENT_WALLCLOCK_WRITES=1` + +### Fixed + +- Hardening pass: exit codes, store repair path, MCP smoke coverage for new tools + +[0.1.0]: https://github.com/TelivityAI/agent-wallclock/releases/tag/v0.1.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..5db13d7 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,76 @@ +# Contributing + +Thanks for helping improve Agent Wallclock. This project is local-first temporal context for language models — contributions should preserve that honesty. + +## Before you start + +1. Read [`README.md`](README.md) for product scope (not a hosted chat UI). +2. Run the [no-leak checklist](docs/NO_LEAK_CHECKLIST.md) before pushing. +3. For pass-batch work, follow [`docs/PASS_PR_NAMING.md`](docs/PASS_PR_NAMING.md). + +## Development setup + +```bash +git clone https://github.com/TelivityAI/agent-wallclock.git +cd agent-wallclock +npm install +npm run build +npm link -w @agent-wallclock/cli +``` + +Verify: + +```bash +wallclock now +wallclock doctor +``` + +## Branch naming + +| Type | Pattern | Example | +|------|---------|---------| +| Pass / hardening | `fix/pass-NNN-slug` | `fix/pass-hardening-100` | +| Feature | `feat/short-slug` | `feat/timeline-json` | +| Docs only | `docs/short-slug` | `docs/mcp-readme` | +| Chore | `chore/short-slug` | `chore/ci-node-22` | + +## Tests required + +Every PR that changes behavior must pass: + +```bash +npm test # unit tests (@agent-wallclock/core) +npm run smoke # build + CLI smoke + MCP smoke +``` + +CI runs the same on Ubuntu with Node 22. Fix failures before requesting review. + +## Pull requests + +- Fill out the [PR template](.github/PULL_REQUEST_TEMPLATE.md). +- One logical change per PR when possible. +- Update adapters when `MODEL_RULES` or briefing shape changes (`adapters/*`, `catalog/models.md`). +- Docs must stay honest: no fake product screenshots; label illustrative UI as **MOCK**. +- Use path placeholders (`/ABSOLUTE/PATH/TO/...`) — never commit home directories or tokens. + +## Code style + +- TypeScript in `packages/*`, ESM, Node 20+ +- Match existing naming and error handling (`CliError`, `ExitCode`) +- Minimize scope — focused diffs over drive-by refactors + +## Adapters and MODEL_RULES + +`packages/core/src/brief.ts` exports `MODEL_RULES`. Host adapters should stay aligned on: + +- Trust briefing only while fresh (**Generated at** / **Stale after**, default 15m) +- Never invent time, session age, or effort duration +- Missing fields → unknown + +## Release and ship + +Maintainers use [`docs/SHIP_CHECKLIST.md`](docs/SHIP_CHECKLIST.md) before tagging. Contributors do not need to cut releases unless asked. + +## Questions + +Open a [feature request](.github/ISSUE_TEMPLATE/feature_request.md) or discussion issue. For vulnerabilities, see [`SECURITY.md`](SECURITY.md). diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..dff0d36 --- /dev/null +++ b/NOTICE @@ -0,0 +1,32 @@ +Agent Wallclock +Copyright 2026 TelivityAI + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +--- + +This product includes software developed by third parties: + +## Model Context Protocol SDK + +- Package: `@modelcontextprotocol/sdk` +- License: MIT +- Use: stdio MCP server transport and tool registration in `@agent-wallclock/mcp` + +## Zod + +- Package: `zod` +- License: MIT +- Use: MCP tool input validation in `@agent-wallclock/mcp` + +Full license texts for dependencies are available in `node_modules/` after `npm install` and in the respective project repositories. diff --git a/README.md b/README.md index 4ebebd9..b3151d9 100644 --- a/README.md +++ b/README.md @@ -27,17 +27,38 @@ No cloud sync. No account. The Wallclock process itself makes no network calls. --- -## Demo: generate a briefing +## Install (canonical) ```bash git clone https://github.com/TelivityAI/agent-wallclock.git cd agent-wallclock -npm install && npm run build - -# put `wallclock` on PATH for this machine +npm install +npm run build npm link -w @agent-wallclock/cli -# or one-off: npm exec -w @agent-wallclock/cli -- wallclock ... +wallclock --help +``` + +Requirements: Node.js 20+. + +One-off without linking: + +```bash +npm exec -w @agent-wallclock/cli -- wallclock --help +# or +node packages/cli/dist/bin.js --help +``` + +### Verify +```bash +wallclock now && wallclock doctor +``` + +`doctor` checks store load, permissions, and that CLI/MCP builds exist. + +### Quick start + +```bash wallclock init wallclock effort start auth-rewrite wallclock session start @@ -59,23 +80,16 @@ Example output (real CLI shape; durations use `d`/`h`/`m`/`s`): ## Now - Local date: 2026-07-31 - Local time: 02:30:22 -- Weekday: Friday -- Timezone: America/Chicago (Central Daylight Time, UTC-05:00) -- ISO (UTC): 2026-07-31T07:30:22.605Z - -## Active session -- Status: open -- Started: 2026-07-31T07:18:22.580Z -- Age: 12m (720025 ms) - -## Active effort -- Name: auth-rewrite -- 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**. +Filled MCP config (absolute server path): + +```bash +wallclock mcp-config --print cursor +wallclock mcp-config --print claude +wallclock mcp-config --print vscode +``` --- @@ -88,14 +102,14 @@ ChatGPT cannot read your disk. You give it two things: | 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 a **fresh** `wallclock brief` into the chat | -![MOCK: ChatGPT custom instructions + pasted briefing (illustrative UI)](docs/images/02-chatgpt-setup.png) +![MOCK (illustrative only — not a live ChatGPT UI capture): custom instructions + pasted briefing](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. Refresh if older than ~15 minutes. +4. Paste into the chat before asking anything time-sensitive. Refresh if **Generated at** is outside the freshness window (~15 minutes default). 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. --- @@ -109,7 +123,7 @@ Two paths (pick one or both): 1. Add [`adapters/claude-project-instructions.md`](adapters/claude-project-instructions.md) to a **Project**’s instructions (or custom instructions). 2. Paste a fresh `wallclock brief` into the chat when you start work. -![MOCK: Claude chat using a Temporal Briefing (illustrative UI)](docs/images/03-claude-chat.png) +![MOCK (illustrative only — not a live Claude UI capture): chat using a Temporal Briefing](docs/images/03-claude-chat.png) ### B) MCP (Claude Desktop) @@ -128,13 +142,11 @@ Three paths (combine freely): | Path | What | |------|------| -| 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` | +| Terminal | Agent runs `npm exec -w @agent-wallclock/cli -- wallclock brief` or absolute `node …/packages/cli/dist/bin.js brief`. Do not assume bare `wallclock` is on PATH. | +| Skill / rule | Copy [`adapters/cursor-skill/`](adapters/cursor-skill/) to `~/.cursor/skills/agent-wallclock/` or project `.cursor/skills/agent-wallclock/`; add the rule fragment from `rule.md` | | MCP | Run `wallclock mcp-config --print cursor` (or edit [`adapters/mcp/cursor-mcp.json`](adapters/mcp/cursor-mcp.json)), enable the server | -![MOCK: Cursor MCP with agent-wallclock tools (illustrative UI)](docs/images/04-cursor-mcp.png) - -**MCP tools:** `get_now`, `get_briefing`, `list_efforts` (read). `start_effort`, `log_session` require `AGENT_WALLCLOCK_WRITES=1`. +![MOCK (illustrative only — not a live Cursor UI capture): MCP with agent-wallclock tools](docs/images/04-cursor-mcp.png) 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.” @@ -150,57 +162,53 @@ When the skill/rule is on, Cursor should call `get_briefing` (or run the CLI) in --- -## Install - -```bash -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 | Command | Purpose | |---------|---------| | `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 (names are slug-normalized) | -| `wallclock session start\|end` | Open/close a work block on an effort | -| `wallclock timeline` | Recent sessions (open rows show live age) | -| `wallclock mcp-config --print ` | Emit filled MCP JSON | +| `wallclock brief` | Full Temporal Briefing (`--copy`, `--json`, `--compact`) | +| `wallclock doctor [--repair]` | Health checks (store, builds, permissions) | +| `wallclock where` | Show store path and config hints | +| `wallclock --version` | Package version | +| `wallclock completion bash\|zsh` | Shell completion script | +| `wallclock effort start\|list\|status\|log` | Named efforts + cumulative time (`list --json`) | +| `wallclock effort rename\|archive\|unarchive\|delete` | Manage efforts (`delete` requires `--confirm`) | +| `wallclock session start\|end\|status` | Open/close/status work block (`start --force` end-and-restart) | +| `wallclock timeline` | Recent sessions (`--json`, `--effort `; open rows show live age) | +| `wallclock store backup\|restore` | Snapshot or restore `store.json` | +| `wallclock mcp-config --print ` | Emit filled MCP JSON (`--check` verifies server build) | | `wallclock init` | Create `~/.agent-wallclock/` and point at adapters | Override store directory: `AGENT_WALLCLOCK_HOME=/path wallclock ...` +Full help: `wallclock --help`. Troubleshooting: [`TROUBLESHOOTING.md`](TROUBLESHOOTING.md). + +--- + +## MCP tools + +| Tool | Access | Purpose | +|------|--------|---------| +| `get_now` | read | System wall clock | +| `get_briefing` | read | Full Temporal Briefing (check freshness) | +| `list_efforts` | read | Efforts and logged durations | +| `get_session_status` | read | Open session live age | +| `get_timeline` | read | Recent sessions | +| `start_effort` | write* | Create/select active effort | +| `log_session` | write* | Session `start` / `end` / `manual` | + +\*Write tools require `AGENT_WALLCLOCK_WRITES=1` in MCP server `env`. Details: [`adapters/mcp/README.md`](adapters/mcp/README.md). + --- ## Privacy - 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 — pasting **does** send that data to the host cloud. -- MCP mutations default **off** (`AGENT_WALLCLOCK_WRITES=1` to enable). +- **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. +- **MCP trust:** when MCP is enabled, the host can **read** your local store via read tools. With `AGENT_WALLCLOCK_WRITES=1`, the host can **write** efforts/sessions too. --- @@ -213,10 +221,15 @@ Override store directory: `AGENT_WALLCLOCK_HOME=/path wallclock ...` | `packages/mcp` | Local stdio MCP server | | `adapters/` | Copy-paste instructions per host | | `catalog/models.md` | Attach points cheat sheet | +| `docs/` | Architecture, checklists, pass naming | | `docs/images/` | Demo screenshots (`01` real CLI; `02`–`04` labeled MOCK) | --- +## Contributing + +See [`CONTRIBUTING.md`](CONTRIBUTING.md). Run `npm test` and `npm run smoke` before PRs. + ## License -Apache-2.0 +Apache-2.0 — see [`LICENSE`](LICENSE) and [`NOTICE`](NOTICE). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..fcb7661 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,50 @@ +# Security policy + +## Supported versions + +| Version | Supported | +|---------|-----------| +| 0.1.x | Yes | + +## Scope + +Agent Wallclock is **local-only** software: CLI and MCP read your system clock and a JSON ledger under `~/.agent-wallclock/`. The project itself makes no outbound network calls. + +Security reports should focus on: + +- Local privilege escalation or unsafe file permissions +- Store corruption, lock handling, or data loss bugs with security impact +- MCP trust boundary issues (unexpected writes, path traversal in store paths) +- Supply-chain concerns in dependencies (`@modelcontextprotocol/sdk`, `zod`, etc.) + +Out of scope for this repo: + +- Host-side paste/upload of briefings to Claude, ChatGPT, or Cursor (that is the host’s privacy model) +- Social engineering via model prompts (mitigated by adapters, not enforced in code) + +## Reporting a vulnerability + +**Preferred:** [GitHub Security Advisories](https://github.com/TelivityAI/agent-wallclock/security/advisories/new) on `TelivityAI/agent-wallclock`. + +**Alternative:** Open a **private** security issue if advisories are unavailable — title prefix `[security]`, minimal reproduction, no secrets in the body. + +Include: + +1. Affected version or commit +2. Steps to reproduce locally +3. Impact assessment (confidentiality / integrity / availability on the user’s machine) +4. Suggested fix if you have one + +## Response expectations + +Maintainers aim to acknowledge reports within **7 days** and provide a triage update within **14 days**. Critical local data-loss or RCE-class issues are prioritized. + +## Safe disclosure + +Please do not open public issues with exploit details before a fix is available. We will coordinate disclosure and credit if you wish. + +## Hardening defaults + +- MCP write tools (`start_effort`, `log_session`) are **off** unless `AGENT_WALLCLOCK_WRITES=1` +- Store directory `0700`, store file `0600` where the OS supports it +- No telemetry or auto-update channels in the core product diff --git a/TROUBLESHOOTING.md b/TROUBLESHOOTING.md new file mode 100644 index 0000000..d0bf2bc --- /dev/null +++ b/TROUBLESHOOTING.md @@ -0,0 +1,128 @@ +# Troubleshooting + +Common problems when building, linking, or running Agent Wallclock locally. + +## Build missing / `wallclock` not found + +**Symptoms:** `command not found: wallclock`, or `CLI build missing` from `wallclock doctor`. + +**Fix:** + +```bash +cd /ABSOLUTE/PATH/TO/agent-wallclock +npm install +npm run build +npm link -w @agent-wallclock/cli # optional but recommended +``` + +One-off without linking: + +```bash +npm exec -w @agent-wallclock/cli -- wallclock --help +# or +node /ABSOLUTE/PATH/TO/agent-wallclock/packages/cli/dist/bin.js --help +``` + +Cursor agents and MCP configs should use **absolute** paths to `packages/cli/dist/bin.js` and `packages/mcp/dist/server.js` — do not assume `wallclock` is on PATH. + +## MCP server path wrong + +**Symptoms:** Host shows MCP server failed to start; `wallclock doctor` reports MCP build missing. + +**Fix:** + +1. Rebuild: `npm run build` +2. Regenerate config: + +```bash +wallclock mcp-config --print cursor --check +wallclock mcp-config --print claude --check +wallclock mcp-config --print vscode --check +``` + +3. Merge the printed JSON into your host MCP settings (Claude Desktop, Cursor, VS Code). +4. Restart the host. + +Templates in `adapters/mcp/` use `/ABSOLUTE/PATH/TO/agent-wallclock` — replace with your clone path or prefer `mcp-config --print`. + +## Stale briefing + +**Symptoms:** Model quotes old local time or session age; briefing **Generated at** is more than ~15 minutes ago. + +**Fix:** + +- Run `wallclock brief --copy` and paste again, or +- Call MCP `get_briefing` before time-based advice. + +Adjust freshness window (optional): + +```bash +export AGENT_WALLCLOCK_STALE_AFTER_MS=900000 # 15m default +``` + +The briefing includes **Stale after** so models know when to refresh. + +## Store lock contention + +**Symptoms:** Exit code `6`, message about `store.lock`, or warning that another process holds the lock. + +**Cause:** Two CLI or MCP processes wrote the store at once (e.g. parallel agents with writes enabled). + +**Fix:** + +1. Close duplicate MCP servers or terminal sessions using Wallclock. +2. If a process crashed, stale lock may remain. Wait a moment and retry. +3. Set `AGENT_WALLCLOCK_LOCK_STRICT=1` to fail fast instead of warning (useful in CI). +4. Never delete `store.lock` while another live process is running. + +## Corrupt or unreadable store + +**Symptoms:** Exit code `5`, `StoreCorruptError`, or JSON parse errors. + +**Fix:** + +1. Diagnose: `wallclock doctor` +2. Attempt repair: `wallclock doctor --repair` (best-effort normalization) +3. Restore from backup if you have one: + +```bash +wallclock store backup /ABSOLUTE/PATH/TO/backups/wallclock-store.json +wallclock store restore /ABSOLUTE/PATH/TO/backups/wallclock-store.json +``` + +4. Last resort: move aside `~/.agent-wallclock/store.json` and run `wallclock init` (loses ledger history). + +Override store location for testing: + +```bash +AGENT_WALLCLOCK_HOME=/tmp/wallclock-test wallclock init +``` + +## Clipboard copy failed + +**Symptoms:** `wallclock brief --copy` prints to stdout with a clipboard error. + +**Fix:** Use `--copy` only where `pbcopy` / `xclip` / `wl-copy` exists, or pipe manually: + +```bash +wallclock brief | pbcopy # macOS +wallclock brief --json | jq . +``` + +## MCP write tools disabled + +**Symptoms:** `start_effort` or `log_session` returns an error about writes disabled. + +**Fix:** Add to the MCP server `env` block in host config: + +```json +"AGENT_WALLCLOCK_WRITES": "1" +``` + +Read tools (`get_now`, `get_briefing`, `list_efforts`, `get_session_status`, `get_timeline`) work without this flag. + +## Still stuck? + +1. `wallclock where` — show store path and config hints +2. `wallclock doctor` — full check list +3. Open a [bug report](.github/ISSUE_TEMPLATE/bug_report.md) with sanitized output (no home paths, no store contents unless you intend to share them) diff --git a/adapters/chatgpt-custom-instructions.md b/adapters/chatgpt-custom-instructions.md index a7d4643..cf553d0 100644 --- a/adapters/chatgpt-custom-instructions.md +++ b/adapters/chatgpt-custom-instructions.md @@ -8,7 +8,7 @@ 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. +- 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. diff --git a/adapters/claude-project-instructions.md b/adapters/claude-project-instructions.md index 98674e5..b1dbab6 100644 --- a/adapters/claude-project-instructions.md +++ b/adapters/claude-project-instructions.md @@ -6,15 +6,17 @@ Add this to a Claude Project’s instructions, or to custom instructions / memor ## Temporal intelligence (Agent Wallclock) -When a Temporal Briefing is available (pasted or via MCP tools `get_now` / `get_briefing`): +When a Temporal Briefing is available (pasted or via MCP tools `get_now` / `get_briefing` / `get_session_status` / `get_timeline`): - 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. +- 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. - 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. +If tools are available, call `get_briefing` before making time-sensitive statements. Use `get_session_status` or `get_timeline` when you need session detail without re-parsing the full briefing. + +Write tools (`start_effort`, `log_session`) require `AGENT_WALLCLOCK_WRITES=1` on the MCP server — do not assume they are enabled. 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 c1ff7b2..4c73a1c 100644 --- a/adapters/cursor-skill/SKILL.md +++ b/adapters/cursor-skill/SKILL.md @@ -11,22 +11,32 @@ description: >- ## Instructions 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 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. + - **Prefer MCP** tool `get_briefing` when the Agent Wallclock MCP server is configured. + - Also available read tools: `get_now`, `list_efforts`, `get_session_status`, `get_timeline`. + - If MCP is unavailable, ask the user to paste `wallclock brief`, or run the CLI via: + - `npm exec -w @agent-wallclock/cli -- wallclock brief` from the repo root, or + - `node /ABSOLUTE/PATH/TO/agent-wallclock/packages/cli/dist/bin.js brief` + - **Do not** assume bare `wallclock` is on PATH — use `npm exec` or an absolute `node` path. +2. Check **Generated at** / **Stale after**. If **Generated-at** is 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 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`. +6. Write tools (`start_effort`, `log_session`) require `AGENT_WALLCLOCK_WRITES=1` — default off. + +## Install skill + +Copy this folder to: + +- `~/.cursor/skills/agent-wallclock/` (user-wide), or +- `.cursor/skills/agent-wallclock/` in your project + +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 get_briefing or a fresh wallclock brief; 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. ``` diff --git a/adapters/cursor-skill/rule.md b/adapters/cursor-skill/rule.md index a02ad38..65a33af 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 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. +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. diff --git a/adapters/generic-system-prompt.md b/adapters/generic-system-prompt.md index adc79cc..5f2607b 100644 --- a/adapters/generic-system-prompt.md +++ b/adapters/generic-system-prompt.md @@ -5,7 +5,7 @@ Paste this into any model’s system prompt or custom instructions. When a Tempo ## Temporal rules 1. Use the Temporal Briefing for local date, local time, timezone, session age, and effort duration. -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. +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. @@ -15,3 +15,7 @@ Paste this into any model’s system prompt or custom instructions. When a Tempo ## When no briefing is available Say that wall-clock and effort duration are unknown, and suggest the user run `wallclock brief` (or call `get_briefing` via MCP) before making time-based claims. + +## MODEL_RULES alignment + +Trust only the Temporal Briefing for clock, session age, and effort duration. Never invent time. Missing fields → unknown. Refresh if stale. diff --git a/adapters/mcp/README.md b/adapters/mcp/README.md index 33549bf..22520cd 100644 --- a/adapters/mcp/README.md +++ b/adapters/mcp/README.md @@ -4,15 +4,50 @@ 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 +node /ABSOLUTE/PATH/TO/agent-wallclock/packages/cli/dist/bin.js mcp-config --print cursor --check +node /ABSOLUTE/PATH/TO/agent-wallclock/packages/cli/dist/bin.js mcp-config --print claude --check +node /ABSOLUTE/PATH/TO/agent-wallclock/packages/cli/dist/bin.js mcp-config --print vscode --check +``` + +Or, if `wallclock` is linked: + +```bash +wallclock mcp-config --print cursor --check ``` 3. Or copy `claude-desktop.json` / `cursor-mcp.json` and replace `/ABSOLUTE/PATH/TO/agent-wallclock` with your clone path. -4. Restart the host. +4. Restart the host (Claude Desktop, Cursor, or VS Code with MCP support). + +## Tools + +| Tool | Access | Notes | +|------|--------|-------| +| `get_now` | read | Local wall clock | +| `get_briefing` | read | Temporal Briefing — check **Generated at** / **Stale after** (default 15m) | +| `list_efforts` | read | Efforts + logged durations | +| `get_session_status` | read | Open session live age | +| `get_timeline` | read | Recent sessions; optional `limit`, `effort` | +| `start_effort` | write | Requires `AGENT_WALLCLOCK_WRITES=1` | +| `log_session` | write | `action`: `start` \| `end` \| `manual` — requires writes flag | + +**Writes default off.** Read tools always work. Mutating tools require `AGENT_WALLCLOCK_WRITES=1` in the server `env` block: + +```json +"env": { + "AGENT_WALLCLOCK_WRITES": "1" +} +``` + +Optional: set `AGENT_WALLCLOCK_HOME` in the server `env` block for a non-default store directory. + +## VS Code + +Use `wallclock mcp-config --print vscode` for a starter JSON fragment. Merge into your VS Code MCP settings per current extension docs, then restart. -**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. +## Trust boundary -Optional: set `AGENT_WALLCLOCK_HOME` in the server `env` block to point at a non-default store directory. +- Server is **stdio-only** — no outbound network from Agent Wallclock. +- Host can read your local store via read tools. +- With writes enabled, host agents can mutate efforts/sessions under the same lock as the CLI. -The server is stdio-only and performs no network I/O. +See [`docs/architecture.md`](../../docs/architecture.md). diff --git a/catalog/models.md b/catalog/models.md index 330e777..afbe85c 100644 --- a/catalog/models.md +++ b/catalog/models.md @@ -7,24 +7,72 @@ Models only stop inventing time **when a fresh briefing or MCP tools are attache ## 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. Refresh if older than ~15 minutes. +- **Per chat**: paste output of `wallclock brief` at the start of a session or when duration matters. Refresh if **Generated-at** is older than the stated freshness window (~15 minutes default). - 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**: run `wallclock mcp-config --print claude` (or edit [`adapters/mcp/claude-desktop.json`](../adapters/mcp/claude-desktop.json)) and use `get_briefing`. +- **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`, `get_session_status`, `get_timeline`. - Fallback: paste a fresh `wallclock brief`. ## Cursor -- **Skill**: copy [`adapters/cursor-skill/`](../adapters/cursor-skill/) into a personal or project skills directory. +- **Skill**: copy [`adapters/cursor-skill/`](../adapters/cursor-skill/) to `~/.cursor/skills/agent-wallclock/` or project `.cursor/skills/agent-wallclock/`. - **User rule**: add the fragment from [`adapters/cursor-skill/rule.md`](../adapters/cursor-skill/rule.md). - **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. +- Agents should use MCP first, else `npm exec -w @agent-wallclock/cli -- wallclock brief` or absolute `node …/packages/cli/dist/bin.js brief` — do not assume `wallclock` is on PATH. ## Generic API / other hosts -- Put [`adapters/generic-system-prompt.md`](../adapters/generic-system-prompt.md) in the system prompt. -- Include a fresh Temporal Briefing in the first user message or a dedicated context message each session. +Use the **API system-prompt pattern**: + +1. **System message**: paste [`adapters/generic-system-prompt.md`](../adapters/generic-system-prompt.md) (or embed `MODEL_RULES` from `wallclock brief --json`). +2. **First user message (or dedicated context message)**: include a fresh Temporal Briefing from `wallclock brief` or your orchestrator calling a local `get_briefing` equivalent. +3. **Refresh**: before time-sensitive tool calls or advice, inject a new briefing if **Generated at** is outside the freshness window. + +Example shape (OpenAI-compatible): + +```json +{ + "messages": [ + { + "role": "system", + "content": "" + }, + { + "role": "user", + "content": "" + }, + { + "role": "user", + "content": "What time is it for me, and how long is my open session?" + } + ] +} +``` + +For JSON pipelines: `wallclock brief --json` includes `generatedAt`, `staleAfterMs`, and `modelRules`. + +## Google Gemini (AI Studio / API) + +Gemini has no first-class MCP in all surfaces. Use the generic paste path: + +- **System instruction** (AI Studio or API `systemInstruction`): paste [`adapters/generic-system-prompt.md`](../adapters/generic-system-prompt.md). +- **User turn**: paste fresh `wallclock brief` at session start; refresh when stale. +- **API**: set `systemInstruction` + include briefing text in the first `contents` user part (same pattern as OpenAI above). + +If you run a local MCP bridge, point it at the same store via `AGENT_WALLCLOCK_HOME` and expose briefing text to your gateway — Agent Wallclock does not ship a hosted bridge. + +## Other hosts (Copilot, local LLMs, etc.) + +- **Paste path**: generic system prompt + periodic `wallclock brief` paste (most portable). +- **MCP path**: any host with stdio MCP can use [`adapters/mcp/README.md`](../adapters/mcp/README.md) and `wallclock mcp-config --print vscode` as a starting point. +- **CLI path**: subprocess `node /ABSOLUTE/PATH/TO/agent-wallclock/packages/cli/dist/bin.js brief --json` from your agent runner. + +Always label illustrative UI screenshots as **MOCK** in docs — do not present fake host screenshots as real product captures. + +## Privacy reminder + +Local CLI/MCP do not network. Pasting or sending briefing text to a cloud API uploads temporal data to that provider. diff --git a/docs/NO_LEAK_CHECKLIST.md b/docs/NO_LEAK_CHECKLIST.md new file mode 100644 index 0000000..9bfb41e --- /dev/null +++ b/docs/NO_LEAK_CHECKLIST.md @@ -0,0 +1,45 @@ +# No-leak pre-push checklist + +Run this before every push, PR, screenshot, or release candidate. Agent Wallclock is local-first; leaks in public repos are permanent. + +## Paths + +- [ ] No home directory paths in diffs (`/Users/…`, `/home/…`, `C:\Users\…`) +- [ ] MCP JSON examples use `/ABSOLUTE/PATH/TO/agent-wallclock` placeholders only +- [ ] Docs and adapters never embed your real clone path — use placeholders or `wallclock mcp-config --print` +- [ ] Screenshots and terminal captures cropped or redacted if they show usernames or paths + +## Secrets and credentials + +- [ ] No API keys, tokens, `.env` contents, or MCP auth headers in commits +- [ ] No private repo URLs with embedded credentials +- [ ] `store.json` backups are **never** committed (local ledger may contain effort names you consider private) + +## Content hygiene + +- [ ] No internal Slack/email threads pasted into docs or commit messages +- [ ] No real customer or employer project names unless you intend them to be public +- [ ] Issue/PR descriptions use generic effort names (`auth-rewrite`) in examples + +## Automated spot-check + +From repo root: + +```bash +# Fail if common leak patterns appear in tracked files (adjust as needed) +git diff --cached | grep -E '/Users/|/home/|sk-[a-zA-Z0-9]{20,}|ghp_[a-zA-Z0-9]+' && echo 'LEAK DETECTED' || echo 'OK' +``` + +## MCP and host config + +- [ ] Committed MCP templates use placeholder paths only +- [ ] Generated configs with real paths stay in your local host config (Claude Desktop, Cursor), not in git +- [ ] `wallclock mcp-config --print … --check` passes before sharing config snippets + +## If you leaked something + +1. Rotate any exposed secret immediately. +2. Remove the leak in a follow-up commit (do not rely on `git revert` alone for secrets — assume they were scraped). +3. For published releases, open a maintainer issue; consider a force-push only with maintainer approval. + +See also: [`SHIP_CHECKLIST.md`](SHIP_CHECKLIST.md), [`CONTRIBUTING.md`](../CONTRIBUTING.md). diff --git a/docs/PASS_PR_NAMING.md b/docs/PASS_PR_NAMING.md new file mode 100644 index 0000000..bdce0bd --- /dev/null +++ b/docs/PASS_PR_NAMING.md @@ -0,0 +1,45 @@ +# Pass PR naming + +Agent Wallclock uses **pass batches** for coordinated hardening, docs, and adapter work. Each pass is a series of focused pull requests against `main`. + +## Branch and PR pattern + +``` +fix/pass-NNN-slug +``` + +| Part | Meaning | +|------|---------| +| `fix/` | Prefix for pass/hardening work (not user-facing features) | +| `pass-NNN` | Pass number, zero-padded to three digits (`001`, `042`, `100`) | +| `slug` | Short kebab-case description of the pass scope | + +### Examples + +- `fix/pass-001-store-migration` — first pass, store version migration +- `fix/pass-042-mcp-tools` — MCP tool additions +- `fix/pass-hardening-100` — **batch umbrella** when several related PRs land together under one pass label + +Use a **batch slug** like `pass-hardening-100` when the work spans docs, adapters, CLI polish, and tests in one coordinated merge window. Individual commits inside the branch can still reference sub-slugs in commit messages. + +## PR title convention + +Match the branch name in the PR title: + +``` +fix/pass-NNN-slug: one-line summary +``` + +Example: `fix/pass-hardening-100: docs, adapters, and ship checklist` + +## What belongs in a pass PR + +- Hardening, bug fixes, docs honesty, adapter sync with `MODEL_RULES` +- Tests and smoke script updates +- No secrets, home paths, or private notes in diffs (see [`NO_LEAK_CHECKLIST.md`](NO_LEAK_CHECKLIST.md)) + +## What does not use pass naming + +- User-facing features: prefer `feat/short-slug` +- Dependency-only bumps: `chore/deps-…` or Dependabot PRs +- Hotfixes on release tags: `fix/release-…` if outside a pass batch diff --git a/docs/RELEASE_NOTES_TEMPLATE.md b/docs/RELEASE_NOTES_TEMPLATE.md new file mode 100644 index 0000000..baf03c9 --- /dev/null +++ b/docs/RELEASE_NOTES_TEMPLATE.md @@ -0,0 +1,59 @@ +# Release notes template + +Copy into GitHub Releases when tagging `vX.Y.Z`. + +--- + +## Agent Wallclock vX.Y.Z + +One-line summary of the release. + +### Highlights + +- Bullet 1 +- Bullet 2 + +### Added + +- … + +### Changed + +- … + +### Fixed + +- … + +### Upgrade + +```bash +git pull +npm install +npm run build +npm link -w @agent-wallclock/cli # if you use linked CLI +wallclock doctor +``` + +### MCP hosts + +Regenerate MCP config after upgrade: + +```bash +wallclock mcp-config --print cursor --check +wallclock mcp-config --print claude --check +``` + +Restart Claude Desktop / Cursor after updating server paths. + +### Breaking changes + +None — or list them explicitly. + +### Full changelog + +See [CHANGELOG.md](https://github.com/TelivityAI/agent-wallclock/blob/main/CHANGELOG.md). + +--- + +**Verify:** `npm test && npm run smoke` on the tagged commit. diff --git a/docs/SHIP_CHECKLIST.md b/docs/SHIP_CHECKLIST.md new file mode 100644 index 0000000..a877d2b --- /dev/null +++ b/docs/SHIP_CHECKLIST.md @@ -0,0 +1,53 @@ +# Ship checklist + +Use before merging a pass batch or tagging a release (e.g. `v0.1.0`). + +## Tests + +- [ ] `npm install` clean on Node 20+ +- [ ] `npm run build` succeeds +- [ ] `npm test` passes (core unit tests) +- [ ] `npm run smoke` passes (CLI + MCP smoke scripts) +- [ ] CI green on the PR branch + +## Local verification + +```bash +wallclock now +wallclock doctor +wallclock brief --compact +wallclock mcp-config --print cursor --check +``` + +- [ ] `doctor` reports store, CLI build, MCP build OK +- [ ] Fresh briefing shows **Generated at** and **Stale after** + +## Docs honesty + +- [ ] README install path is single canonical flow (clone → install → build → link) +- [ ] Images `02`–`04` labeled **MOCK** (illustrative UI only) +- [ ] Image `01` is real CLI or clearly described as captured output +- [ ] No fake ChatGPT/Claude/Cursor product screenshots presented as real +- [ ] Privacy section covers clipboard paste + MCP host read/write when writes enabled +- [ ] Commands and MCP tools tables match CLI/MCP source +- [ ] Adapters aligned with `MODEL_RULES` freshness language + +## No leaks + +- [ ] [`NO_LEAK_CHECKLIST.md`](NO_LEAK_CHECKLIST.md) complete +- [ ] No `/Users/` or home paths in diff +- [ ] MCP examples use `/ABSOLUTE/PATH/TO/...` placeholders +- [ ] No tokens, `.env`, or personal store backups in commits + +## Release artifacts (when tagging) + +- [ ] [`CHANGELOG.md`](../CHANGELOG.md) updated for the version +- [ ] GitHub Release notes from template (`.github/release.yml` or [`RELEASE_NOTES_TEMPLATE.md`](RELEASE_NOTES_TEMPLATE.md)) +- [ ] Tag `vX.Y.Z` matches `package.json` / workspace versions +- [ ] [`SECURITY.md`](../SECURITY.md) supported versions table updated if needed + +## Post-ship + +- [ ] Verify issue/PR templates render on GitHub +- [ ] Dependabot PRs triaged or merged +- [ ] Announce breaking changes in release notes only if semver major (future) diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..6f8572d --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,104 @@ +# Architecture + +Agent Wallclock is a **local-only** temporal context layer for language models. It does not run a chat UI, sync to the cloud, or call remote APIs. + +## Components + +```text +┌─────────────────────────────────────────────────────────────┐ +│ Host (Claude / ChatGPT / Cursor) │ +│ • paste briefing OR MCP stdio client │ +└───────────────────────────┬─────────────────────────────────┘ + │ user-controlled attach +┌───────────────────────────▼─────────────────────────────────┐ +│ packages/cli packages/mcp │ +│ wallclock binary stdio MCP server │ +└───────────────────────────┬─────────────────────────────────┘ + │ +┌───────────────────────────▼─────────────────────────────────┐ +│ packages/core │ +│ clock · store · efforts · sessions · briefing · doctor │ +└───────────────────────────┬─────────────────────────────────┘ + │ +┌───────────────────────────▼─────────────────────────────────┐ +│ ~/.agent-wallclock/store.json (+ store.lock) │ +│ system clock (Date / Intl) │ +└─────────────────────────────────────────────────────────────┘ +``` + +| Package | Role | +|---------|------| +| `@agent-wallclock/core` | Shared logic: time formatting, JSON store, briefing renderer, config from env | +| `@agent-wallclock/cli` | User-facing `wallclock` commands | +| `@agent-wallclock/mcp` | MCP tool surface over the same core | + +## Store + +- **Location:** `~/.agent-wallclock/` by default; override with `AGENT_WALLCLOCK_HOME`. +- **File:** `store.json` — efforts, sessions, active pointers, schema version. +- **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. + +Store operations are synchronous and local. Corruption triggers `StoreCorruptError`; `wallclock doctor --repair` attempts normalization. + +## Briefing + +The **Temporal Briefing** is markdown (or JSON with `--json`) built from: + +1. System clock (`getNow`) +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`) + +`MODEL_RULES` in the briefing instruct models to trust the briefing only while fresh and never invent durations. + +Adapters in `adapters/` propagate the same rules into host-specific instructions. + +## MCP trust boundary + +The MCP server runs **on the user’s machine** as a child process of the host (stdio transport). + +### Read path (default) + +Tools: `get_now`, `get_briefing`, `list_efforts`, `get_session_status`, `get_timeline`. + +- Read system clock and store +- No network I/O +- Cannot mutate ledger without writes flag + +### Write path (opt-in) + +Set `AGENT_WALLCLOCK_WRITES=1` in the MCP server environment. + +Tools: `start_effort`, `log_session` (`start` / `end` / `manual`). + +- Same lock and store as CLI +- Host agent can mutate efforts/sessions — user must trust the host + enabled writes + +### Local-only guarantee + +Neither CLI nor MCP opens outbound sockets. **Upload happens only when the user pastes a briefing into a cloud host** or when the host sends MCP tool results to its backend — that is outside this repo’s control. + +## Configuration + +All runtime tuning is environment-based (`packages/core/src/config.ts`): + +| Variable | Default | Purpose | +|----------|---------|---------| +| `AGENT_WALLCLOCK_STALE_AFTER_MS` | 900000 (15m) | Briefing freshness window | +| `AGENT_WALLCLOCK_OPEN_SESSION_WARN_MS` | 4h | Open session warning in briefing | +| `AGENT_WALLCLOCK_OPEN_SESSION_SOFT_CAP_MS` | 8h | Soft-cap note in briefing | +| `AGENT_WALLCLOCK_TIMELINE_LIMIT` | 20 | Default timeline rows | +| `AGENT_WALLCLOCK_LOCK_STRICT` | off | Fail on lock contention | + +## Adapters + +Host-specific markdown in `adapters/` is **soft enforcement** — models comply only when instructions and fresh data are attached. See [`catalog/models.md`](../catalog/models.md) for attach points. + +## Testing + +- **Unit:** `npm test` (core store, briefing, clock) +- **Smoke:** `npm run smoke` (CLI + MCP integration scripts) + +See [`CONTRIBUTING.md`](../CONTRIBUTING.md) and [`TROUBLESHOOTING.md`](../TROUBLESHOOTING.md). diff --git a/docs/npm-scope.md b/docs/npm-scope.md new file mode 100644 index 0000000..81a2eab --- /dev/null +++ b/docs/npm-scope.md @@ -0,0 +1,50 @@ +# npm scope: `@agent-wallclock` + +The `@agent-wallclock` npm scope is **org-owned** under [TelivityAI](https://github.com/TelivityAI). + +## Packages (planned / private monorepo) + +| Package | Purpose | +|---------|---------| +| `@agent-wallclock/core` | Clock, store, briefing logic | +| `@agent-wallclock/cli` | `wallclock` CLI binary | +| `@agent-wallclock/mcp` | Local stdio MCP server | + +Current repo version: **0.1.0** (workspace packages, root `"private": true`). + +## Ownership + +- Scope registration and package names are controlled by TelivityAI maintainers. +- External publishes to `@agent-wallclock/*` without org approval are not permitted. +- Dependabot and CI may bump dependencies; **npm publish** is maintainer-gated. + +## Installing from source (today) + +Public install path is **clone + build + link**, not npm registry: + +```bash +git clone https://github.com/TelivityAI/agent-wallclock.git +cd agent-wallclock +npm install && npm run build +npm link -w @agent-wallclock/cli +``` + +One-off: + +```bash +npm exec -w @agent-wallclock/cli -- wallclock --help +``` + +## Future registry publish + +When packages are published to npm: + +- Versions will follow semver starting at `0.1.0` +- Release notes via GitHub Releases (see `.github/release.yml` / [`RELEASE_NOTES_TEMPLATE.md`](RELEASE_NOTES_TEMPLATE.md)) +- Provenance and 2FA will be required for org publishes + +## Related docs + +- [`CONTRIBUTING.md`](../CONTRIBUTING.md) +- [`CHANGELOG.md`](../CHANGELOG.md) +- [`docs/SHIP_CHECKLIST.md`](SHIP_CHECKLIST.md) diff --git a/package-lock.json b/package-lock.json index a1d38aa..ccddecc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1226,7 +1226,7 @@ "version": "0.1.0", "license": "Apache-2.0", "dependencies": { - "@agent-wallclock/core": "0.1.0" + "@agent-wallclock/core": "*" }, "bin": { "wallclock": "dist/bin.js" @@ -1234,6 +1234,9 @@ "devDependencies": { "@types/node": "^22.13.10", "typescript": "^5.8.2" + }, + "engines": { + "node": ">=20" } }, "packages/core": { @@ -1243,6 +1246,9 @@ "devDependencies": { "@types/node": "^22.13.10", "typescript": "^5.8.2" + }, + "engines": { + "node": ">=20" } }, "packages/mcp": { @@ -1250,7 +1256,7 @@ "version": "0.1.0", "license": "Apache-2.0", "dependencies": { - "@agent-wallclock/core": "0.1.0", + "@agent-wallclock/core": "*", "@modelcontextprotocol/sdk": "^1.12.1", "zod": "^3.24.2" }, @@ -1260,6 +1266,9 @@ "devDependencies": { "@types/node": "^22.13.10", "typescript": "^5.8.2" + }, + "engines": { + "node": ">=20" } } } diff --git a/package.json b/package.json index cf692af..6dee0f8 100644 --- a/package.json +++ b/package.json @@ -9,9 +9,12 @@ ], "scripts": { "build": "npm run build -w @agent-wallclock/core && npm run build -w @agent-wallclock/cli && npm run build -w @agent-wallclock/mcp", - "test": "npm run test -w @agent-wallclock/core", + "test": "npm run test -w @agent-wallclock/core && npm run test -w @agent-wallclock/cli", "smoke": "npm run build && node scripts/smoke.mjs && node scripts/mcp-smoke.mjs", - "wallclock": "node packages/cli/dist/bin.js" + "wallclock": "node -e \"require('fs').accessSync('packages/cli/dist/bin.js')\" && node packages/cli/dist/bin.js", + "qa:local": "node scripts/qa-local.mjs", + "lint": "npm run lint -w @agent-wallclock/core && npm run lint -w @agent-wallclock/cli && npm run lint -w @agent-wallclock/mcp", + "publish:dry-run": "node scripts/publish-dry-run.mjs" }, "engines": { "node": ">=20" diff --git a/packages/cli/.npmignore b/packages/cli/.npmignore new file mode 100644 index 0000000..7489f28 --- /dev/null +++ b/packages/cli/.npmignore @@ -0,0 +1,4 @@ +*.test.js +*.test.d.ts +*.test.js.map +*.tsbuildinfo diff --git a/packages/cli/README.md b/packages/cli/README.md new file mode 100644 index 0000000..397cdd6 --- /dev/null +++ b/packages/cli/README.md @@ -0,0 +1,49 @@ +# @agent-wallclock/cli + +Command-line interface for Agent Wallclock (`wallclock` binary). + +**License:** Apache-2.0 + +## Install from source + +```bash +cd /ABSOLUTE/PATH/TO/agent-wallclock +npm install && npm run build +npm link -w @agent-wallclock/cli +wallclock --help +``` + +One-off without linking: + +```bash +npm exec -w @agent-wallclock/cli -- wallclock now +node packages/cli/dist/bin.js doctor +``` + +## Common commands + +| Command | Purpose | +|---------|---------| +| `wallclock now` | Local time snapshot | +| `wallclock brief [--copy\|--json\|--compact]` | Temporal Briefing | +| `wallclock doctor` | Health checks | +| `wallclock where` | Store and paths | +| `wallclock mcp-config --print ` | MCP JSON | +| `wallclock init` | Initialize store | + +Full list: `wallclock --help` or [README](../../README.md). + +## Environment + +- `AGENT_WALLCLOCK_HOME` — store directory +- `AGENT_WALLCLOCK_STALE_AFTER_MS` — briefing freshness window + +No network calls. Pasting a briefing uploads data to the host you paste into. + +## Build + +```bash +npm run build -w @agent-wallclock/cli +``` + +Depends on `@agent-wallclock/core`. diff --git a/packages/cli/package.json b/packages/cli/package.json index 3018dbb..00c71bb 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -8,13 +8,20 @@ "wallclock": "./dist/bin.js" }, "files": [ - "dist" + "dist", + "README.md" ], "scripts": { - "build": "tsc -p tsconfig.json" + "build": "tsc -p tsconfig.json", + "test": "npm run build && node --test --test-reporter=spec dist/*.test.js", + "lint": "tsc -p tsconfig.json --noEmit", + "prepublishOnly": "npm run build" }, "dependencies": { - "@agent-wallclock/core": "0.1.0" + "@agent-wallclock/core": "*" + }, + "engines": { + "node": ">=20" }, "devDependencies": { "@types/node": "^22.13.10", diff --git a/packages/cli/src/bin.test.ts b/packages/cli/src/bin.test.ts new file mode 100644 index 0000000..074c81d --- /dev/null +++ b/packages/cli/src/bin.test.ts @@ -0,0 +1,60 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import { describe, it, afterEach } from "node:test"; +import { fileURLToPath } from "node:url"; + +const bin = fileURLToPath(new URL("../dist/bin.js", import.meta.url)); + +let home = ""; + +function run(args: string[], env: Record = {}) { + return spawnSync(process.execPath, [bin, ...args], { + encoding: "utf8", + env: { ...process.env, AGENT_WALLCLOCK_HOME: home, ...env }, + }); +} + +describe("wallclock CLI", () => { + afterEach(() => { + if (home) { + rmSync(home, { recursive: true, force: true }); + home = ""; + } + }); + + it("isolates store via AGENT_WALLCLOCK_HOME", () => { + home = mkdtempSync(join(tmpdir(), "wallclock-cli-test-")); + const init = run(["init"]); + assert.equal(init.status, 0, init.stderr || init.stdout); + const where = run(["where"]); + assert.equal(where.status, 0, where.stderr || where.stdout); + assert.ok(where.stdout.includes(home), "where should report isolated home"); + }); + + it("logs multi-word effort durations", () => { + home = mkdtempSync(join(tmpdir(), "wallclock-cli-test-")); + assert.equal(run(["init"]).status, 0); + assert.equal(run(["effort", "start", "auth rewrite"]).status, 0); + const log = run(["effort", "log", "auth", "rewrite", "45m"]); + assert.equal(log.status, 0, log.stderr || log.stdout); + assert.match(log.stdout, /Logged .* on "auth-rewrite"/); + }); + + it("returns nonzero exit for unknown commands", () => { + home = mkdtempSync(join(tmpdir(), "wallclock-cli-test-")); + assert.equal(run(["init"]).status, 0); + const bad = run(["not-a-command"]); + assert.notEqual(bad.status, 0); + assert.match(bad.stderr || bad.stdout, /Unknown command/); + }); + + it("uses stable exit codes for usage errors", () => { + home = mkdtempSync(join(tmpdir(), "wallclock-cli-test-")); + assert.equal(run(["init"]).status, 0); + const usage = run(["effort", "log"]); + assert.equal(usage.status, 2, usage.stderr || usage.stdout); + }); +}); diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts index 4de77cb..af71002 100644 --- a/packages/cli/src/bin.ts +++ b/packages/cli/src/bin.ts @@ -2,6 +2,7 @@ import { readFileSync, existsSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { createRequire } from "node:module"; import { getNow, initStore, @@ -12,43 +13,108 @@ import { listEfforts, effortStatus, logManualDuration, + renameEffort, + archiveEffort, + deleteEffort, startSession, endSession, + sessionStatus, timeline, renderBriefing, + renderBriefingCompact, + buildBriefingInput, formatDuration, parseDuration, + backupStore, + restoreStore, + runDoctor, + loadConfig, + classifyError, + CliError, + ExitCode, + MODEL_RULES, } from "@agent-wallclock/core"; -import { copyToClipboard } from "./copy.js"; +import { copyToClipboard, clipboardHint } from "./copy.js"; const STORE_DIR = process.env.AGENT_WALLCLOCK_HOME?.trim() || getDefaultStoreDir(); const HERE = dirname(fileURLToPath(import.meta.url)); +const require = createRequire(import.meta.url); + +function packageVersion(): string { + try { + const pkg = require("../package.json") as { version?: string }; + return pkg.version ?? "0.0.0"; + } catch { + return "0.0.0"; + } +} + +function colorEnabled(): boolean { + if (process.env.NO_COLOR != null && process.env.NO_COLOR !== "") return false; + if (process.env.FORCE_COLOR === "0") return false; + return Boolean(process.stdout.isTTY); +} + +function paint(text: string, code: string): string { + if (!colorEnabled()) return text; + return `\u001b[${code}m${text}\u001b[0m`; +} function printHelp(): void { console.log(`Agent Wallclock — local temporal context for language models Usage: wallclock now - wallclock brief [--copy] + wallclock brief [--copy] [--json] [--compact] wallclock effort start - wallclock effort list + wallclock effort list [--json] [--all] wallclock effort status [name] + wallclock effort rename + wallclock effort archive + wallclock effort unarchive + wallclock effort delete --confirm wallclock effort log # duration is the last token (e.g. 30m) - wallclock session start [effort] + wallclock session start [effort] [--force] wallclock session end - wallclock timeline [limit] - wallclock mcp-config --print + wallclock session status + wallclock timeline [limit] [--json] [--effort ] + wallclock store backup [path] + wallclock store restore + wallclock doctor [--repair] + wallclock where + wallclock mcp-config --print [--check] + wallclock init + wallclock completion bash|zsh + wallclock --version + +Examples: wallclock init + wallclock effort start auth-rewrite + wallclock session start + wallclock brief --copy + wallclock brief --json | jq .generatedAt + wallclock timeline 10 --effort auth-rewrite + wallclock mcp-config --print cursor --check + wallclock doctor 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_WRITES=1 Enable MCP write tools (start_effort, log_session) + AGENT_WALLCLOCK_HOME Override store directory (default: ~/.agent-wallclock) + AGENT_WALLCLOCK_WRITES=1 Enable MCP write tools (start_effort, log_session) + AGENT_WALLCLOCK_STALE_AFTER_MS Briefing freshness window (default 900000 = 15m) + AGENT_WALLCLOCK_OPEN_SESSION_WARN_MS Open-session warning threshold (default 4h) + AGENT_WALLCLOCK_OPEN_SESSION_SOFT_CAP_MS Soft-cap note threshold (default 8h) + AGENT_WALLCLOCK_TIMELINE_LIMIT Default timeline row limit (default 20) + AGENT_WALLCLOCK_LOCK_STRICT=1 Fail on store lock contention instead of warning + NO_COLOR Disable ANSI colors Privacy: store is local JSON only. No network calls from this CLI. +Pastes upload briefing data to the host. MCP with writes enabled can mutate the ledger. + +Exit codes: 0 ok | 2 usage | 3 not found | 4 conflict | 5 store | 6 lock | 7 io | 1 other `); } @@ -75,6 +141,29 @@ function mcpServerPath(): string { return candidates[0]!; } +function cliBinPath(): string { + return resolve(HERE, "bin.js"); +} + +function takeFlag(args: string[], flag: string): boolean { + const idx = args.indexOf(flag); + if (idx >= 0) { + args.splice(idx, 1); + return true; + } + return false; +} + +function takeOption(args: string[], flag: string): string | undefined { + const idx = args.indexOf(flag); + if (idx >= 0) { + const val = args[idx + 1]; + args.splice(idx, 2); + return val; + } + return undefined; +} + function cmdNow(): void { const now = getNow(); console.log(`Local date: ${now.localDate}`); @@ -85,16 +174,30 @@ function cmdNow(): void { } 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 text = renderBriefing(store); - const copy = args.includes("--copy"); + + let text: string; + if (asJson) { + const input = buildBriefingInput(store); + text = `${JSON.stringify({ ...input, modelRules: MODEL_RULES }, null, 2)}\n`; + } else if (compact) { + text = `${renderBriefingCompact(store)}\n`; + } else { + text = renderBriefing(store); + } + if (copy) { const ok = copyToClipboard(text); if (ok) { console.log("Temporal Briefing copied to clipboard."); } else { - console.error("Could not copy to clipboard; printing instead."); - console.log(text); + console.error(`Could not copy to clipboard. ${clipboardHint()}`); + console.error("Printing instead:"); + process.stdout.write(text); + process.exitCode = ExitCode.IO; } } else { process.stdout.write(text); @@ -104,22 +207,32 @@ function cmdBrief(args: string[]): void { function cmdEffort(args: string[]): void { const sub = args[0]; if (!sub) { - throw new Error("Missing effort subcommand. Try: start | list | status | log"); + throw new CliError( + "Missing effort subcommand. Try: start | list | status | log | rename | archive | delete", + ExitCode.USAGE, + ); } if (sub === "start") { const name = args.slice(1).join(" ").trim(); - if (!name) throw new Error("Usage: wallclock effort start "); + if (!name) throw new CliError("Usage: wallclock effort start ", ExitCode.USAGE); let created = false; let effortName = ""; let effortId = ""; + let nearName = ""; updateStore(STORE_DIR, (store) => { const result = startEffort(store, name); created = result.created; effortName = result.effort.name; effortId = result.effort.id; + nearName = result.nearDuplicate?.name ?? ""; return result.store; }); + if (nearName) { + console.error( + `warning: "${effortName}" is similar to existing effort "${nearName}"`, + ); + } console.log( created ? `Started effort "${effortName}" (${effortId})` @@ -129,17 +242,37 @@ function cmdEffort(args: string[]): void { } if (sub === "list") { + const asJson = takeFlag(args, "--json"); + const includeArchived = takeFlag(args, "--all"); const store = loadStore(STORE_DIR); - const efforts = listEfforts(store); + const efforts = listEfforts(store, { includeArchived }); + if (asJson) { + console.log( + JSON.stringify( + efforts.map((e) => { + const status = effortStatus(store, e.id); + return { + ...e, + loggedMs: status.totalMs, + active: store.activeEffortId === e.id, + }; + }), + null, + 2, + ), + ); + return; + } if (efforts.length === 0) { console.log("No efforts yet."); return; } for (const e of efforts) { - const active = store.activeEffortId === e.id ? " [active]" : ""; + const active = store.activeEffortId === e.id ? paint(" [active]", "32") : ""; + const archived = e.archived ? paint(" [archived]", "33") : ""; const status = effortStatus(store, e.id); console.log( - `${e.name}${active} logged=${formatDuration(status.totalMs)} sessions=${e.sessionCount} started=${e.startedAt}`, + `${e.name}${active}${archived} logged=${formatDuration(status.totalMs)} sessions=${e.sessionCount} started=${e.startedAt} (UTC)`, ); } return; @@ -155,21 +288,80 @@ function cmdEffort(args: string[]): void { } console.log(`Name: ${status.effort.name}`); console.log(`Active: ${status.isActive ? "yes" : "no"}`); - console.log(`Started: ${status.effort.startedAt}`); + console.log(`Archived: ${status.effort.archived ? "yes" : "no"}`); + console.log(`Started: ${status.effort.startedAt} (UTC)`); console.log(`Calendar age: ${formatDuration(status.ageMs)}`); 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"}`); + console.log( + `Last activity: ${status.effort.lastActivityAt != null ? `${status.effort.lastActivityAt} (UTC)` : "unknown"}`, + ); + return; + } + + if (sub === "rename") { + const rest = args.slice(1); + if (rest.length < 2) { + throw new CliError("Usage: wallclock effort rename ", ExitCode.USAGE); + } + const newName = rest[rest.length - 1]!; + const oldName = rest.slice(0, -1).join(" ").trim(); + let effortName = ""; + updateStore(STORE_DIR, (store) => { + const result = renameEffort(store, oldName, newName); + effortName = result.effort.name; + return result.store; + }); + console.log(`Renamed effort to "${effortName}"`); + return; + } + + if (sub === "archive" || sub === "unarchive") { + const name = args.slice(1).join(" ").trim(); + if (!name) throw new CliError(`Usage: wallclock effort ${sub} `, ExitCode.USAGE); + let effortName = ""; + updateStore(STORE_DIR, (store) => { + const result = archiveEffort(store, name, sub === "archive"); + effortName = result.effort.name; + return result.store; + }); + console.log( + sub === "archive" + ? `Archived effort "${effortName}"` + : `Unarchived effort "${effortName}"`, + ); + return; + } + + if (sub === "delete") { + const confirm = takeFlag(args, "--confirm"); + const name = args.slice(1).join(" ").trim(); + if (!name) { + throw new CliError("Usage: wallclock effort delete --confirm", ExitCode.USAGE); + } + if (!confirm) { + throw new CliError( + `Refusing to delete without --confirm. Usage: wallclock effort delete ${name} --confirm`, + ExitCode.USAGE, + ); + } + let effortName = ""; + updateStore(STORE_DIR, (store) => { + const result = deleteEffort(store, name); + effortName = result.deleted.name; + return result.store; + }); + console.log(`Deleted effort "${effortName}" and its sessions`); return; } if (sub === "log") { if (args.length < 3) { - throw new Error("Usage: wallclock effort log "); + throw new CliError("Usage: wallclock effort log ", ExitCode.USAGE); } const dur = args[args.length - 1]!; const name = args.slice(1, -1).join(" ").trim(); - if (!name) throw new Error("Usage: wallclock effort log "); + if (!name) throw new CliError("Usage: wallclock effort log ", ExitCode.USAGE); const ms = parseDuration(dur); let effortName = ""; let totalMs = 0; @@ -185,21 +377,27 @@ function cmdEffort(args: string[]): void { return; } - throw new Error(`Unknown effort subcommand: ${sub}`); + throw new CliError(`Unknown effort subcommand: ${sub}`, ExitCode.USAGE); } function cmdSession(args: string[]): void { const sub = args[0]; if (sub === "start") { + const force = takeFlag(args, "--force"); const effort = args.slice(1).join(" ").trim() || undefined; let effortName = ""; let sessionId = ""; + let forced = false; updateStore(STORE_DIR, (store) => { - const result = startSession(store, effort); + const result = startSession(store, effort, new Date(), { force }); effortName = result.effort.name; sessionId = result.session.id; + forced = Boolean(result.forcedEnd); return result.store; }); + if (forced) { + console.log(`Ended previous open session (force).`); + } console.log(`Session open on "${effortName}" (${sessionId})`); return; } @@ -219,58 +417,175 @@ function cmdSession(args: string[]): void { ); return; } - throw new Error("Usage: wallclock session start [effort] | wallclock session end"); + if (sub === "status") { + const store = loadStore(STORE_DIR); + const st = sessionStatus(store); + if (!st.session) { + console.log("No open session."); + return; + } + console.log(`Status: open`); + console.log(`Id: ${st.session.id}`); + console.log(`Effort: ${st.effort?.name ?? "unknown"}`); + console.log(`Started: ${st.session.startedAt} (UTC)`); + console.log(`Age: ${formatDuration(st.ageMs)} (${st.ageMs ?? 0} ms)`); + const cfg = loadConfig(); + if (st.ageMs != null && st.ageMs >= cfg.openSessionWarnAfterMs) { + console.log( + `Warning: older than ${formatDuration(cfg.openSessionWarnAfterMs)}`, + ); + } + if (st.ageMs != null && st.ageMs >= cfg.openSessionSoftCapMs) { + console.log( + `Soft cap: exceeded ${formatDuration(cfg.openSessionSoftCapMs)}`, + ); + } + return; + } + throw new CliError( + "Usage: wallclock session start [effort] [--force] | end | status", + ExitCode.USAGE, + ); } function cmdTimeline(args: string[]): void { - const limit = args[0] ? Number(args[0]) : 20; + const asJson = takeFlag(args, "--json"); + const effortFilter = takeOption(args, "--effort"); + const cfg = loadConfig(); + const limitRaw = args[0]; + const limit = limitRaw ? Number(limitRaw) : cfg.timelineDefaultLimit; if (!Number.isFinite(limit) || limit <= 0) { - throw new Error("timeline limit must be a positive number"); + throw new CliError("timeline limit must be a positive number", ExitCode.USAGE); } const store = loadStore(STORE_DIR); - const rows = timeline(store, limit); + if (store.sessions.length >= cfg.hugeStoreSessionWarn) { + console.error( + `warning: store has ${store.sessions.length} sessions; showing ${limit} (set AGENT_WALLCLOCK_TIMELINE_LIMIT or pass an explicit limit)`, + ); + } + const rows = timeline(store, { limit, effortName: effortFilter }); + if (asJson) { + console.log(JSON.stringify(rows, null, 2)); + return; + } if (rows.length === 0) { console.log("No sessions yet."); return; } for (const row of rows) { const dur = row.durationMs != null ? formatDuration(row.durationMs) : "unknown"; + const state = row.endedAt ? "closed" : paint("open", "32"); console.log( - `${row.startedAt} ${row.effortName} ${dur} ${row.endedAt ? "closed" : "open"}`, + `${row.startedAt} (UTC) ${row.effortName} ${dur} ${state}`, ); } } +function cmdStore(args: string[]): void { + const sub = args[0]; + if (sub === "backup") { + const dest = args[1]; + const path = backupStore(STORE_DIR, dest); + console.log(`Backup written to ${path}`); + return; + } + if (sub === "restore") { + const path = args[1]; + if (!path) throw new CliError("Usage: wallclock store restore ", ExitCode.USAGE); + const store = restoreStore(path, STORE_DIR); + console.log( + `Restored store from ${path} (${store.efforts.length} efforts, ${store.sessions.length} sessions)`, + ); + return; + } + throw new CliError("Usage: wallclock store backup [path] | restore ", ExitCode.USAGE); +} + +function cmdDoctor(args: string[]): void { + const repair = takeFlag(args, "--repair"); + const result = runDoctor({ + storeDir: STORE_DIR, + mcpServerPath: mcpServerPath(), + cliBinPath: cliBinPath(), + repair, + }); + for (const check of result.checks) { + const mark = check.ok ? paint("ok", "32") : paint("FAIL", "31"); + console.log(`[${mark}] ${check.id}: ${check.message}`); + } + if (!result.ok) { + throw new CliError("Doctor found problems. See above.", ExitCode.STORE); + } + console.log("Doctor OK"); +} + +function cmdWhere(): void { + const server = mcpServerPath(); + console.log(`store_dir: ${STORE_DIR}`); + console.log(`store_file: ${join(STORE_DIR, "store.json")}`); + console.log(`mcp_server: ${server}`); + console.log(`mcp_exists: ${existsSync(server) ? "yes" : "no"}`); + console.log(`cli_bin: ${cliBinPath()}`); + console.log(`version: ${packageVersion()}`); +} + function cmdMcpConfig(args: string[]): void { - const print = args.includes("--print"); - const host = args.find((a) => a === "claude" || a === "cursor"); + const print = takeFlag(args, "--print"); + const check = takeFlag(args, "--check"); + const host = args.find((a) => a === "claude" || a === "cursor" || a === "vscode"); if (!print || !host) { - throw new Error("Usage: wallclock mcp-config --print "); + throw new CliError( + "Usage: wallclock mcp-config --print [--check]", + ExitCode.USAGE, + ); } 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.`, - ); + if (check || !existsSync(server)) { + if (!existsSync(server)) { + throw new CliError( + `MCP server build not found at ${server}. Run \`npm run build\` from the repo root first.`, + ExitCode.NOT_FOUND, + ); + } } - const config = { - mcpServers: { - "agent-wallclock": { - command: "node", - args: [server], - env: { - AGENT_WALLCLOCK_WRITES: "0", + const env: Record = { + AGENT_WALLCLOCK_WRITES: "0", + }; + + let config: unknown; + if (host === "vscode") { + // VS Code / Copilot MCP uses mcp.json servers map (same shape family as Cursor). + config = { + servers: { + "agent-wallclock": { + type: "stdio", + command: "node", + args: [server], + env, }, }, - }, - }; + }; + } else { + config = { + mcpServers: { + "agent-wallclock": { + command: "node", + args: [server], + env, + }, + }, + }; + } 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."); + if (check) { + console.error("# --check: server file exists"); + } } function cmdInit(): void { @@ -278,11 +593,23 @@ function cmdInit(): void { console.log(`Store ready at ${STORE_DIR}`); console.log(`Efforts: ${store.efforts.length}; sessions: ${store.sessions.length}`); console.log(""); + + const doctor = runDoctor({ + storeDir: STORE_DIR, + mcpServerPath: mcpServerPath(), + cliBinPath: cliBinPath(), + }); + for (const check of doctor.checks) { + const mark = check.ok ? "ok" : "FAIL"; + console.log(`[${mark}] ${check.id}: ${check.message}`); + } + console.log(""); console.log("Next:"); 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(" 4. wallclock mcp-config --print cursor --check"); + console.log(" 5. wallclock doctor"); console.log(""); const root = adaptersRoot(); if (root) { @@ -311,12 +638,75 @@ function cmdInit(): void { } } +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 effort_subs="start list status log rename archive unarchive delete" + local session_subs="start end status" + local store_subs="backup restore" + if [[ \${COMP_CWORD} -eq 1 ]]; then + COMPREPLY=( $(compgen -W "\$cmds" -- "\$cur") ) + elif [[ \${COMP_WORDS[1]} == effort && \${COMP_CWORD} -eq 2 ]]; then + COMPREPLY=( $(compgen -W "\$effort_subs" -- "\$cur") ) + elif [[ \${COMP_WORDS[1]} == session && \${COMP_CWORD} -eq 2 ]]; then + 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]} == mcp-config ]]; then + COMPREPLY=( $(compgen -W "--print --check claude cursor vscode" -- "\$cur") ) + elif [[ \${COMP_WORDS[1]} == completion ]]; then + COMPREPLY=( $(compgen -W "bash zsh" -- "\$cur") ) + fi +} +complete -F _wallclock wallclock +`); + return; + } + if (shell === "zsh") { + console.log(`#compdef wallclock +_wallclock() { + local -a cmds + cmds=(now brief 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 ;; + mcp-config) _values 'mcp' --print --check claude cursor vscode ;; + completion) _values 'shell' bash zsh ;; + esac +} +compdef _wallclock wallclock +`); + return; + } + throw new CliError("Usage: wallclock completion bash|zsh", ExitCode.USAGE); +} + +function ensureDistPresent(): void { + if (!existsSync(cliBinPath())) { + console.error( + `wallclock build missing at ${cliBinPath()}. From the repo root run: npm run build`, + ); + process.exit(ExitCode.NOT_FOUND); + } +} + function main(argv: string[]): void { + ensureDistPresent(); const [cmd, ...rest] = argv; if (!cmd || cmd === "-h" || cmd === "--help" || cmd === "help") { printHelp(); return; } + if (cmd === "--version" || cmd === "-V" || cmd === "version") { + console.log(packageVersion()); + return; + } switch (cmd) { case "now": @@ -334,21 +724,33 @@ function main(argv: string[]): void { case "timeline": cmdTimeline(rest); break; + case "store": + cmdStore(rest); + break; + case "doctor": + cmdDoctor(rest); + break; + case "where": + cmdWhere(); + break; case "mcp-config": cmdMcpConfig(rest); break; case "init": cmdInit(); break; + case "completion": + cmdCompletion(rest); + break; default: - throw new Error(`Unknown command: ${cmd}`); + throw new CliError(`Unknown command: ${cmd}`, ExitCode.USAGE); } } try { main(process.argv.slice(2)); } catch (err) { - const message = err instanceof Error ? err.message : String(err); + const { message, exitCode } = classifyError(err); console.error(message); - process.exitCode = 1; + process.exitCode = exitCode; } diff --git a/packages/cli/src/copy.ts b/packages/cli/src/copy.ts index 0710570..a51b7bf 100644 --- a/packages/cli/src/copy.ts +++ b/packages/cli/src/copy.ts @@ -1,6 +1,17 @@ import { spawnSync } from "node:child_process"; import { platform } from "node:os"; +export function clipboardHint(): string { + const os = platform(); + if (os === "darwin") { + return "On macOS, ensure `pbcopy` is available in PATH."; + } + if (os === "win32") { + return "On Windows, ensure `clip` is available (usually built-in)."; + } + return "On Linux, install `wl-copy` (Wayland) or `xclip` (X11)."; +} + export function copyToClipboard(text: string): boolean { const os = platform(); try { diff --git a/packages/core/.npmignore b/packages/core/.npmignore new file mode 100644 index 0000000..7489f28 --- /dev/null +++ b/packages/core/.npmignore @@ -0,0 +1,4 @@ +*.test.js +*.test.d.ts +*.test.js.map +*.tsbuildinfo diff --git a/packages/core/README.md b/packages/core/README.md new file mode 100644 index 0000000..661ea2e --- /dev/null +++ b/packages/core/README.md @@ -0,0 +1,36 @@ +# @agent-wallclock/core + +Core library for Agent Wallclock: system clock, local JSON store, efforts, sessions, and Temporal Briefing rendering. + +**License:** Apache-2.0 + +## Exports + +- `getNow`, `formatDuration`, `parseDuration` +- `loadStore`, `updateStore`, `initStore`, `backupStore`, `restoreStore` +- Effort and session operations (`startEffort`, `startSession`, `endSession`, …) +- `renderBriefing`, `renderBriefingCompact`, `buildBriefingInput`, `MODEL_RULES` +- `runDoctor`, `loadConfig`, typed errors (`CliError`, `StoreCorruptError`, …) + +## Store + +Default directory: `~/.agent-wallclock/` (`store.json`, `store.lock`). Override with `AGENT_WALLCLOCK_HOME`. + +## Config (environment) + +| Variable | Default | +|----------|---------| +| `AGENT_WALLCLOCK_STALE_AFTER_MS` | 15m | +| `AGENT_WALLCLOCK_OPEN_SESSION_WARN_MS` | 4h | +| `AGENT_WALLCLOCK_OPEN_SESSION_SOFT_CAP_MS` | 8h | + +See [`docs/architecture.md`](../../docs/architecture.md). + +## Build and test + +```bash +npm run build -w @agent-wallclock/core +npm test -w @agent-wallclock/core +``` + +Used by `@agent-wallclock/cli` and `@agent-wallclock/mcp` — not typically imported by end users directly. diff --git a/packages/core/package.json b/packages/core/package.json index fd76b22..b6c76c4 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -13,11 +13,18 @@ } }, "files": [ - "dist" + "dist", + "README.md" ], "scripts": { "build": "tsc -p tsconfig.json", - "test": "npm run build && node --test dist/format.test.js dist/store.test.js dist/effort-session.test.js" + "test": "npm run build && node --test --test-reporter=spec dist/*.test.js", + "test:coverage": "npm run build && node --test --experimental-test-coverage --test-reporter=spec dist/*.test.js", + "lint": "tsc -p tsconfig.json --noEmit", + "prepublishOnly": "npm run build" + }, + "engines": { + "node": ">=20" }, "devDependencies": { "@types/node": "^22.13.10", diff --git a/packages/core/src/brief.ts b/packages/core/src/brief.ts index 6aa5768..92a56b6 100644 --- a/packages/core/src/brief.ts +++ b/packages/core/src/brief.ts @@ -1,14 +1,18 @@ import { getNow } from "./clock.js"; +import { loadConfig } from "./config.js"; import { ageMs, formatDuration } from "./format.js"; import { getActiveSession } from "./session.js"; -import type { BriefingInput, StoreData } from "./types.js"; +import type { BriefingInput, BriefingOptions, StoreData } from "./types.js"; -/** Briefings older than this should be refreshed before time-based advice. */ +/** @deprecated Prefer loadConfig().staleAfterMs */ export const BRIEFING_STALE_AFTER_MS = 15 * 60 * 1000; -/** Open sessions older than this get a warning in the briefing. */ +/** @deprecated Prefer loadConfig().openSessionWarnAfterMs */ export const OPEN_SESSION_WARN_AFTER_MS = 4 * 60 * 60 * 1000; +/** @deprecated Prefer loadConfig().openSessionSoftCapMs */ +export const OPEN_SESSION_SOFT_CAP_MS = 8 * 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.", @@ -19,8 +23,18 @@ export const MODEL_RULES = [ export function buildBriefingInput( store: StoreData, - nowDate: Date = new Date(), + nowDateOrOpts: Date | BriefingOptions = new Date(), ): BriefingInput { + const opts: BriefingOptions = + nowDateOrOpts instanceof Date ? { nowDate: nowDateOrOpts } : nowDateOrOpts; + const cfg = loadConfig(); + const nowDate = opts.nowDate ?? new Date(); + const staleAfterMs = opts.staleAfterMs ?? cfg.staleAfterMs; + const openSessionWarnAfterMs = + opts.openSessionWarnAfterMs ?? cfg.openSessionWarnAfterMs; + const openSessionSoftCapMs = + opts.openSessionSoftCapMs ?? cfg.openSessionSoftCapMs; + const now = getNow(nowDate); const activeSession = getActiveSession(store); const activeEffort = store.activeEffortId @@ -47,19 +61,30 @@ export function buildBriefingInput( return { now, generatedAt: now.iso, - staleAfterMs: BRIEFING_STALE_AFTER_MS, + staleAfterMs, activeEffort, activeSession, sessionAgeMs, effortAgeMs, effortTotalMs, openSessionWarn: - sessionAgeMs != null && sessionAgeMs >= OPEN_SESSION_WARN_AFTER_MS, + sessionAgeMs != null && sessionAgeMs >= openSessionWarnAfterMs, + openSessionCapNote: + sessionAgeMs != null && sessionAgeMs >= openSessionSoftCapMs, }; } -export function renderBriefing(store: StoreData, nowDate: Date = new Date()): string { - const input = buildBriefingInput(store, nowDate); +export function renderBriefing( + store: StoreData, + nowDateOrOpts: Date | BriefingOptions = new Date(), +): string { + const opts: BriefingOptions = + nowDateOrOpts instanceof Date ? { nowDate: nowDateOrOpts } : nowDateOrOpts; + const input = buildBriefingInput(store, opts); + const cfg = loadConfig(); + const warnAfter = opts.openSessionWarnAfterMs ?? cfg.openSessionWarnAfterMs; + const softCap = opts.openSessionSoftCapMs ?? cfg.openSessionSoftCapMs; + const lines: string[] = [ "# Temporal Briefing (Agent Wallclock)", "", @@ -79,11 +104,16 @@ export function renderBriefing(store: StoreData, nowDate: Date = new Date()): st if (input.activeSession && input.sessionAgeMs != null) { lines.push(`- Status: open`); - lines.push(`- Started: ${input.activeSession.startedAt}`); + lines.push(`- Started: ${input.activeSession.startedAt} (UTC)`); 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\``, + `- Warning: open session older than ${formatDuration(warnAfter)} — confirm it is still intentional, or run \`wallclock session end\``, + ); + } + if (input.openSessionCapNote) { + lines.push( + `- Soft cap: open session exceeded ${formatDuration(softCap)} — consider ending and starting a fresh session block`, ); } } else { @@ -96,7 +126,7 @@ export function renderBriefing(store: StoreData, nowDate: Date = new Date()): st if (input.activeEffort) { lines.push(`- Name: ${input.activeEffort.name}`); - lines.push(`- Started: ${input.activeEffort.startedAt}`); + lines.push(`- Started: ${input.activeEffort.startedAt} (UTC)`); lines.push( `- Calendar age: ${input.effortAgeMs != null ? formatDuration(input.effortAgeMs) : "unknown"}`, ); @@ -104,7 +134,9 @@ export function renderBriefing(store: StoreData, nowDate: Date = new Date()): st `- Logged work time: ${input.effortTotalMs != null ? formatDuration(input.effortTotalMs) : "unknown"} (includes open session if any)`, ); lines.push(`- Sessions: ${input.activeEffort.sessionCount}`); - lines.push(`- Last activity: ${input.activeEffort.lastActivityAt ?? "unknown"}`); + lines.push( + `- Last activity: ${input.activeEffort.lastActivityAt != null ? `${input.activeEffort.lastActivityAt} (UTC)` : "unknown"}`, + ); } else { lines.push(`- Name: none`); lines.push(`- Calendar age: unknown`); @@ -118,3 +150,26 @@ export function renderBriefing(store: StoreData, nowDate: Date = new Date()): st return lines.join("\n"); } + +/** One-screen compact briefing for terminals. */ +export function renderBriefingCompact( + store: StoreData, + nowDateOrOpts: Date | BriefingOptions = new Date(), +): string { + const opts: BriefingOptions = + nowDateOrOpts instanceof Date ? { nowDate: nowDateOrOpts } : nowDateOrOpts; + const input = buildBriefingInput(store, opts); + const session = + input.activeSession && input.sessionAgeMs != null + ? `open ${formatDuration(input.sessionAgeMs)}` + : "none"; + const effort = input.activeEffort + ? `${input.activeEffort.name} logged=${formatDuration(input.effortTotalMs)}` + : "none"; + return [ + `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"); +} diff --git a/packages/core/src/clock.test.ts b/packages/core/src/clock.test.ts new file mode 100644 index 0000000..aac93a5 --- /dev/null +++ b/packages/core/src/clock.test.ts @@ -0,0 +1,38 @@ +import assert from "node:assert/strict"; +import { afterEach, describe, it } from "node:test"; +import { getNow, setClock, resetClock } from "./clock.js"; + +describe("clock", () => { + afterEach(() => { + resetClock(); + }); + + it("supports injectable clock for deterministic tests", () => { + setClock(() => new Date("2026-06-15T18:30:45.000Z")); + const now = getNow(); + assert.equal(now.iso, "2026-06-15T18:30:45.000Z"); + assert.equal(now.epochMs, Date.parse("2026-06-15T18:30:45.000Z")); + assert.match(now.iso, /Z$/); + }); + + it("formats local fields from a fixed instant (DST-safe fixture)", () => { + // Fixed UTC instant; local date/time depend on host TZ but must be stable for that host. + const fixed = new Date("2026-03-08T12:00:00.000Z"); // US spring-forward weekend + const a = getNow(fixed); + const b = getNow(fixed); + assert.equal(a.iso, b.iso); + assert.equal(a.localDate, b.localDate); + assert.equal(a.localTime, b.localTime); + assert.equal(a.weekday, b.weekday); + assert.ok(a.timezone.includes("UTC")); + assert.match(a.timezoneOffset, /^[+-]\d{2}:\d{2}$/); + }); + + it("handles winter DST fixture consistently", () => { + const winter = new Date("2026-11-01T12:00:00.000Z"); // US fall-back weekend + const info = getNow(winter); + assert.equal(info.iso, "2026-11-01T12:00:00.000Z"); + assert.ok(info.localDate.length === 10); + assert.ok(info.localTime.length === 8); + }); +}); diff --git a/packages/core/src/clock.ts b/packages/core/src/clock.ts index ecca667..69c72ba 100644 --- a/packages/core/src/clock.ts +++ b/packages/core/src/clock.ts @@ -10,6 +10,23 @@ const WEEKDAYS = [ "Saturday", ] as const; +/** Injectable clock for deterministic tests. Defaults to system time. */ +export type Clock = () => Date; + +let clockImpl: Clock = () => new Date(); + +export function setClock(clock: Clock): void { + clockImpl = clock; +} + +export function resetClock(): void { + clockImpl = () => new Date(); +} + +export function now(): Date { + return clockImpl(); +} + function pad(n: number, width = 2): string { return String(n).padStart(width, "0"); } @@ -23,7 +40,7 @@ function formatOffset(date: Date): string { return `${sign}${hours}:${minutes}`; } -export function getTimezoneName(date: Date = new Date()): string { +export function getTimezoneName(date: Date = now()): string { try { const parts = new Intl.DateTimeFormat("en-US", { timeZoneName: "long", @@ -42,7 +59,7 @@ export function getIanaTimeZone(): string { } } -export function getNow(date: Date = new Date()): NowInfo { +export function getNow(date: Date = now()): NowInfo { const localDate = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; const localTime = `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; const iana = getIanaTimeZone(); diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts new file mode 100644 index 0000000..6ca8812 --- /dev/null +++ b/packages/core/src/config.ts @@ -0,0 +1,58 @@ +/** + * Runtime config from environment (and optional overrides). + * All values are local-only; none trigger network I/O. + */ + +const DEFAULT_STALE_AFTER_MS = 15 * 60 * 1000; +const DEFAULT_OPEN_SESSION_WARN_MS = 4 * 60 * 60 * 1000; +const DEFAULT_OPEN_SESSION_SOFT_CAP_MS = 8 * 60 * 60 * 1000; +const DEFAULT_TIMELINE_LIMIT = 20; +const DEFAULT_HUGE_STORE_SESSIONS = 500; + +function parsePositiveInt(raw: string | undefined, fallback: number): number { + if (raw == null || raw.trim() === "") return fallback; + const n = Number(raw); + if (!Number.isFinite(n) || n <= 0) return fallback; + return Math.floor(n); +} + +export interface WallclockConfig { + staleAfterMs: number; + openSessionWarnAfterMs: number; + openSessionSoftCapMs: number; + timelineDefaultLimit: number; + hugeStoreSessionWarn: number; +} + +export function loadConfig(env: NodeJS.ProcessEnv = process.env): WallclockConfig { + return { + staleAfterMs: parsePositiveInt( + env.AGENT_WALLCLOCK_STALE_AFTER_MS, + DEFAULT_STALE_AFTER_MS, + ), + openSessionWarnAfterMs: parsePositiveInt( + env.AGENT_WALLCLOCK_OPEN_SESSION_WARN_MS, + DEFAULT_OPEN_SESSION_WARN_MS, + ), + openSessionSoftCapMs: parsePositiveInt( + env.AGENT_WALLCLOCK_OPEN_SESSION_SOFT_CAP_MS, + DEFAULT_OPEN_SESSION_SOFT_CAP_MS, + ), + timelineDefaultLimit: parsePositiveInt( + env.AGENT_WALLCLOCK_TIMELINE_LIMIT, + DEFAULT_TIMELINE_LIMIT, + ), + hugeStoreSessionWarn: parsePositiveInt( + env.AGENT_WALLCLOCK_HUGE_STORE_SESSIONS, + DEFAULT_HUGE_STORE_SESSIONS, + ), + }; +} + +export { + DEFAULT_STALE_AFTER_MS, + DEFAULT_OPEN_SESSION_WARN_MS, + DEFAULT_OPEN_SESSION_SOFT_CAP_MS, + DEFAULT_TIMELINE_LIMIT, + DEFAULT_HUGE_STORE_SESSIONS, +}; diff --git a/packages/core/src/doctor.ts b/packages/core/src/doctor.ts new file mode 100644 index 0000000..209078e --- /dev/null +++ b/packages/core/src/doctor.ts @@ -0,0 +1,105 @@ +import { existsSync, statSync } from "node:fs"; +import { loadConfig } from "./config.js"; +import { loadStore, getStoreFilePath, getDefaultStoreDir } from "./store.js"; + +export interface DoctorCheck { + id: string; + ok: boolean; + message: string; +} + +export interface DoctorResult { + ok: boolean; + checks: DoctorCheck[]; +} + +export interface DoctorOptions { + storeDir?: string; + mcpServerPath?: string; + cliBinPath?: string; + repair?: boolean; +} + +export function runDoctor(opts: DoctorOptions = {}): DoctorResult { + const storeDir = opts.storeDir ?? getDefaultStoreDir(); + const checks: DoctorCheck[] = []; + + // Store load + try { + const store = loadStore(storeDir, { repair: opts.repair }); + checks.push({ + id: "store-load", + ok: true, + message: `Store OK at ${storeDir} (v${store.version}, ${store.efforts.length} efforts, ${store.sessions.length} sessions)`, + }); + const cfg = loadConfig(); + if (store.sessions.length >= cfg.hugeStoreSessionWarn) { + checks.push({ + id: "store-size", + ok: true, + message: `Large store: ${store.sessions.length} sessions (warn threshold ${cfg.hugeStoreSessionWarn}). Timeline defaults to a limited window.`, + }); + } + } catch (err) { + checks.push({ + id: "store-load", + ok: false, + message: err instanceof Error ? err.message : String(err), + }); + } + + const storeFile = getStoreFilePath(storeDir); + if (existsSync(storeFile) && process.platform !== "win32") { + try { + const mode = statSync(storeFile).mode & 0o777; + checks.push({ + id: "store-perms", + ok: mode === 0o600, + message: + mode === 0o600 + ? `Store file mode is 0600` + : `Store file mode is ${mode.toString(8)} (expected 0600)`, + }); + } catch (err) { + checks.push({ + id: "store-perms", + ok: false, + message: err instanceof Error ? err.message : String(err), + }); + } + } + + if (opts.cliBinPath) { + const ok = existsSync(opts.cliBinPath); + checks.push({ + id: "cli-build", + ok, + message: ok + ? `CLI build present: ${opts.cliBinPath}` + : `CLI build missing: ${opts.cliBinPath} — run npm run build`, + }); + } + + if (opts.mcpServerPath) { + const ok = existsSync(opts.mcpServerPath); + checks.push({ + id: "mcp-build", + ok, + message: ok + ? `MCP server present: ${opts.mcpServerPath}` + : `MCP server missing: ${opts.mcpServerPath} — run npm run build`, + }); + } + + const nodeMajor = Number(process.versions.node.split(".")[0]); + checks.push({ + id: "node-engine", + ok: nodeMajor >= 20, + message: `Node ${process.versions.node} (requires >=20)`, + }); + + return { + ok: checks.every((c) => c.ok), + checks, + }; +} diff --git a/packages/core/src/effort-session.test.ts b/packages/core/src/effort-session.test.ts index c0fbecb..20c987d 100644 --- a/packages/core/src/effort-session.test.ts +++ b/packages/core/src/effort-session.test.ts @@ -1,9 +1,17 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { emptyStore } from "./store.js"; -import { startEffort, effortStatus, findEffort } from "./effort.js"; -import { startSession, endSession, timeline } from "./session.js"; -import { buildBriefingInput, renderBriefing } from "./brief.js"; +import { + startEffort, + effortStatus, + findEffort, + renameEffort, + archiveEffort, + deleteEffort, + findNearDuplicateEffort, +} from "./effort.js"; +import { startSession, endSession, timeline, sessionStatus } from "./session.js"; +import { buildBriefingInput, renderBriefing, renderBriefingCompact, MODEL_RULES } from "./brief.js"; describe("effort and session", () => { it("accumulates session duration into effort total", () => { @@ -35,25 +43,92 @@ describe("effort and session", () => { assert.ok(findEffort(again.store, "AUTH rewrite")); }); - it("includes open session age in effort status total", () => { + it("includes open session age in effort status total matching briefing forever", () => { 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); + const brief = buildBriefingInput(store, t1); assert.equal(status.totalMs, 12 * 60 * 1000); + assert.equal(brief.effortTotalMs, status.totalMs); }); - it("timeline reports live age for open sessions", () => { + it("rejects a second session start without --force", () => { + let store = emptyStore(); + const t0 = new Date("2026-03-01T10:00:00.000Z"); + ({ store } = startEffort(store, "auth-rewrite", t0)); + ({ store } = startSession(store, undefined, t0)); + assert.throws(() => startSession(store, undefined, t0), /already open/); + }); + + it("force end-and-restart session", () => { + let store = emptyStore(); + const t0 = new Date("2026-03-01T10:00:00.000Z"); + const t1 = new Date("2026-03-01T10:10:00.000Z"); + ({ store } = startEffort(store, "auth-rewrite", t0)); + ({ store } = startSession(store, undefined, t0)); + const forced = startSession(store, undefined, t1, { force: true }); + store = forced.store; + assert.ok(forced.forcedEnd); + assert.equal(forced.forcedEnd?.endedAt, t1.toISOString()); + assert.equal(store.efforts[0]?.totalMs, 10 * 60 * 1000); + assert.ok(store.activeSessionId); + }); + + it("session status reports live age", () => { + let store = emptyStore(); + const t0 = new Date("2026-03-01T10:00:00.000Z"); + const t1 = new Date("2026-03-01T10:07:00.000Z"); + ({ store } = startEffort(store, "auth-rewrite", t0)); + ({ store } = startSession(store, undefined, t0)); + const st = sessionStatus(store, t1); + assert.equal(st.ageMs, 7 * 60 * 1000); + assert.equal(st.effort?.name, "auth-rewrite"); + }); + + it("timeline reports live age for open sessions and filters by effort", () => { 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); + store = endSession(store, t1).store; + ({ store } = startEffort(store, "other", t1)); + ({ store } = startSession(store, undefined, t1)); + const rows = timeline(store, { limit: 5, now: new Date("2026-03-01T10:08:00.000Z") }); + assert.equal(rows[0]?.durationMs, 3 * 60 * 1000); assert.equal(rows[0]?.endedAt, null); + const filtered = timeline(store, { + limit: 10, + effortName: "auth-rewrite", + now: t1, + }); + assert.equal(filtered.length, 1); + assert.equal(filtered[0]?.effortName, "auth-rewrite"); + }); + + it("rename archive delete efforts", () => { + let store = emptyStore(); + const t0 = new Date("2026-03-01T10:00:00.000Z"); + ({ store } = startEffort(store, "old-name", t0)); + ({ store } = renameEffort(store, "old-name", "new-name")); + assert.equal(store.efforts[0]?.name, "new-name"); + ({ store } = archiveEffort(store, "new-name")); + assert.equal(store.efforts[0]?.archived, true); + assert.equal(store.activeEffortId, null); + ({ store } = deleteEffort(store, "new-name")); + assert.equal(store.efforts.length, 0); + }); + + it("warns on near-duplicate names", () => { + let store = emptyStore(); + ({ store } = startEffort(store, "auth-rewrite", new Date("2026-03-01T10:00:00.000Z"))); + const near = findNearDuplicateEffort(store, "auth-rewrte"); + assert.equal(near?.name, "auth-rewrite"); + const created = startEffort(store, "auth-rewrte", new Date("2026-03-01T11:00:00.000Z")); + assert.equal(created.nearDuplicate?.name, "auth-rewrite"); }); it("briefing marks missing session as unknown and includes freshness", () => { @@ -70,5 +145,26 @@ describe("effort and session", () => { assert.match(text, /09:30:00/); assert.match(text, /Generated at:/); assert.match(text, /Stale after:/); + assert.match(text, /ISO \(UTC\)/); + assert.ok(MODEL_RULES.includes("Trust only this Temporal Briefing")); + + const compact = renderBriefingCompact(store, new Date("2026-03-01T09:30:00.000Z")); + assert.match(compact, /iso\(UTC\)/); + assert.match(compact, /session none/); + }); + + it("briefing notes soft cap on long open sessions", () => { + let store = emptyStore(); + const t0 = new Date("2026-03-01T00:00:00.000Z"); + const t1 = new Date("2026-03-01T09:00:00.000Z"); + ({ store } = startEffort(store, "long", t0)); + ({ store } = startSession(store, undefined, t0)); + const text = renderBriefing(store, { + nowDate: t1, + openSessionSoftCapMs: 8 * 60 * 60 * 1000, + openSessionWarnAfterMs: 4 * 60 * 60 * 1000, + }); + assert.match(text, /Warning:/); + assert.match(text, /Soft cap:/); }); }); diff --git a/packages/core/src/effort.ts b/packages/core/src/effort.ts index 3b16fdd..7e9a987 100644 --- a/packages/core/src/effort.ts +++ b/packages/core/src/effort.ts @@ -14,6 +14,46 @@ export function slugifyEffortName(name: string): string { .replace(/^-+|-+$/g, ""); } +/** Levenshtein distance for near-duplicate warnings. */ +export function editDistance(a: string, b: string): number { + if (a === b) return 0; + if (a.length === 0) return b.length; + if (b.length === 0) return a.length; + const row = Array.from({ length: b.length + 1 }, (_, i) => i); + for (let i = 0; i < a.length; i++) { + let prev = i; + for (let j = 0; j < b.length; j++) { + const cur = row[j + 1]!; + const cost = a[i] === b[j] ? 0 : 1; + row[j + 1] = Math.min(row[j + 1]! + 1, row[j]! + 1, prev + cost); + prev = cur; + } + } + return row[b.length]!; +} + +export function findNearDuplicateEffort( + store: StoreData, + name: string, + maxDistance = 2, +): Effort | undefined { + const slug = slugifyEffortName(name); + if (!slug) return undefined; + let best: Effort | undefined; + let bestDist = Infinity; + for (const e of store.efforts) { + if (e.archived) continue; + const other = slugifyEffortName(e.name); + if (other === slug) continue; + const d = editDistance(slug, other); + if (d > 0 && d <= maxDistance && d < bestDist) { + best = e; + bestDist = d; + } + } + return best; +} + export function findEffort(store: StoreData, nameOrId: string): Effort | undefined { const key = nameOrId.trim(); if (!key) return undefined; @@ -28,7 +68,7 @@ export function startEffort( store: StoreData, name: string, now: Date = new Date(), -): { store: StoreData; effort: Effort; created: boolean } { +): { store: StoreData; effort: Effort; created: boolean; nearDuplicate?: Effort } { const slug = slugifyEffortName(name); if (!slug) { throw new Error("Effort name is required"); @@ -36,6 +76,15 @@ export function startEffort( const existing = findEffort(store, slug); if (existing) { + if (existing.archived) { + const unarchived: Effort = { ...existing, archived: false }; + const next: StoreData = { + ...store, + efforts: store.efforts.map((e) => (e.id === existing.id ? unarchived : e)), + activeEffortId: existing.id, + }; + return { store: next, effort: unarchived, created: false }; + } const next: StoreData = { ...store, activeEffortId: existing.id, @@ -43,6 +92,7 @@ export function startEffort( return { store: next, effort: existing, created: false }; } + const nearDuplicate = findNearDuplicateEffort(store, slug); const effort: Effort = { id: newId("eff"), name: slug, @@ -58,15 +108,91 @@ export function startEffort( activeEffortId: effort.id, }; - return { store: next, effort, created: true }; + return { store: next, effort, created: true, nearDuplicate }; +} + +export function renameEffort( + store: StoreData, + nameOrId: string, + newName: string, +): { store: StoreData; effort: Effort } { + const effort = findEffort(store, nameOrId); + if (!effort) { + throw new Error(`Effort not found: ${nameOrId}`); + } + const slug = slugifyEffortName(newName); + if (!slug) { + throw new Error("New effort name is required"); + } + const clash = findEffort(store, slug); + if (clash && clash.id !== effort.id) { + throw new Error(`Effort name already in use: ${slug}`); + } + const updated: Effort = { ...effort, name: slug }; + const next: StoreData = { + ...store, + efforts: store.efforts.map((e) => (e.id === effort.id ? updated : e)), + }; + return { store: next, effort: updated }; +} + +export function archiveEffort( + store: StoreData, + nameOrId: string, + archived = true, +): { store: StoreData; effort: Effort } { + const effort = findEffort(store, nameOrId); + if (!effort) { + throw new Error(`Effort not found: ${nameOrId}`); + } + const updated: Effort = { ...effort, archived }; + let next: StoreData = { + ...store, + efforts: store.efforts.map((e) => (e.id === effort.id ? updated : e)), + }; + if (archived && next.activeEffortId === effort.id) { + next = { ...next, activeEffortId: null }; + } + return { store: next, effort: updated }; } -export function listEfforts(store: StoreData): Effort[] { - return [...store.efforts].sort((a, b) => { - const aTime = Date.parse(a.lastActivityAt ?? a.startedAt); - const bTime = Date.parse(b.lastActivityAt ?? b.startedAt); - return bTime - aTime; - }); +export function deleteEffort( + store: StoreData, + nameOrId: string, +): { store: StoreData; deleted: Effort } { + const effort = findEffort(store, nameOrId); + if (!effort) { + throw new Error(`Effort not found: ${nameOrId}`); + } + const open = store.activeSessionId + ? store.sessions.find((s) => s.id === store.activeSessionId) + : undefined; + if (open && open.effortId === effort.id) { + throw new Error( + `Cannot delete effort "${effort.name}" while a session is open on it. End the session first.`, + ); + } + const next: StoreData = { + ...store, + efforts: store.efforts.filter((e) => e.id !== effort.id), + sessions: store.sessions.filter((s) => s.effortId !== effort.id), + activeEffortId: store.activeEffortId === effort.id ? null : store.activeEffortId, + }; + return { store: next, deleted: effort }; +} + +export function listEfforts( + store: StoreData, + opts: { includeArchived?: boolean } = {}, +): Effort[] { + const includeArchived = opts.includeArchived ?? false; + return [...store.efforts] + .filter((e) => includeArchived || !e.archived) + .sort((a, b) => { + const aTime = Date.parse(a.lastActivityAt ?? a.startedAt); + const bTime = Date.parse(b.lastActivityAt ?? b.startedAt); + return bTime - aTime; + }); } export function effortStatus( diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts new file mode 100644 index 0000000..19ed8c4 --- /dev/null +++ b/packages/core/src/errors.ts @@ -0,0 +1,51 @@ +/** Stable CLI exit codes by error class. */ +export const ExitCode = { + OK: 0, + GENERIC: 1, + USAGE: 2, + NOT_FOUND: 3, + CONFLICT: 4, + STORE: 5, + LOCK: 6, + IO: 7, +} as const; + +export type ExitCodeValue = (typeof ExitCode)[keyof typeof ExitCode]; + +export class CliError extends Error { + readonly exitCode: ExitCodeValue; + + constructor(message: string, exitCode: ExitCodeValue = ExitCode.GENERIC) { + super(message); + this.name = "CliError"; + this.exitCode = exitCode; + } +} + +export function classifyError(err: unknown): { message: string; exitCode: ExitCodeValue } { + if (err instanceof CliError) { + return { message: err.message, exitCode: err.exitCode }; + } + const message = err instanceof Error ? err.message : String(err); + const name = err instanceof Error ? err.name : ""; + + if (name === "StoreLockError" || /store lock/i.test(message)) { + return { message, exitCode: ExitCode.LOCK }; + } + if (name === "StoreCorruptError" || /corrupt|invalid Agent Wallclock store/i.test(message)) { + return { message, exitCode: ExitCode.STORE }; + } + if (/not found/i.test(message)) { + return { message, exitCode: ExitCode.NOT_FOUND }; + } + if (/already open|already in use|Cannot delete/i.test(message)) { + return { message, exitCode: ExitCode.CONFLICT }; + } + if (/Usage:|Missing |required|Unknown command|Unknown .* subcommand|Invalid duration|must be a positive/i.test(message)) { + return { message, exitCode: ExitCode.USAGE }; + } + if (/Could not read|Failed to save|EACCES|ENOENT|EPERM/i.test(message)) { + return { message, exitCode: ExitCode.IO }; + } + return { message, exitCode: ExitCode.GENERIC }; +} diff --git a/packages/core/src/format.test.ts b/packages/core/src/format.test.ts index 54f08df..c82a30a 100644 --- a/packages/core/src/format.test.ts +++ b/packages/core/src/format.test.ts @@ -2,20 +2,40 @@ 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"; +import { slugifyEffortName, editDistance } from "./effort.js"; describe("formatDuration", () => { it("returns unknown for missing values", () => { assert.equal(formatDuration(null), "unknown"); assert.equal(formatDuration(undefined), "unknown"); assert.equal(formatDuration(Number.NaN), "unknown"); + assert.equal(formatDuration(-1), "unknown"); }); - it("formats short and multi-unit durations", () => { - assert.equal(formatDuration(5000), "5s"); - assert.equal(formatDuration(65_000), "1m"); + it("formats edge boundaries", () => { + assert.equal(formatDuration(0), "0s"); + assert.equal(formatDuration(999), "0s"); + assert.equal(formatDuration(1000), "1s"); + assert.equal(formatDuration(59_000), "59s"); + assert.equal(formatDuration(60_000), "1m"); + assert.equal(formatDuration(61_000), "1m 1s"); + assert.equal(formatDuration(9 * 60_000 + 59_000), "9m 59s"); + assert.equal(formatDuration(10 * 60_000), "10m"); + assert.equal(formatDuration(10 * 60_000 + 30_000), "10m"); assert.equal(formatDuration(3_661_000), "1h 1m"); + assert.equal(formatDuration(86_400_000), "1d"); assert.equal(formatDuration(90_000_000), "1d 1h"); + assert.equal(formatDuration(14 * 86_400_000), "14d"); + }); + + it("keeps day-only units (no weeks)", () => { + assert.equal(formatDuration(7 * 86_400_000), "7d"); + assert.ok(!formatDuration(14 * 86_400_000).includes("w")); + }); + + it("shows seconds under 10 minutes", () => { + assert.equal(formatDuration(5_000), "5s"); + assert.equal(formatDuration(5 * 60_000 + 30_000), "5m 30s"); }); }); @@ -25,6 +45,7 @@ describe("parseDuration", () => { assert.equal(parseDuration("2h"), 2 * 3_600_000); assert.equal(parseDuration("90s"), 90_000); assert.equal(parseDuration("1d"), 86_400_000); + assert.equal(parseDuration("500ms"), 500); }); it("rejects invalid input", () => { @@ -39,3 +60,10 @@ describe("slugifyEffortName", () => { assert.equal(slugifyEffortName(" AUTH rewrite "), "auth-rewrite"); }); }); + +describe("editDistance", () => { + it("measures near duplicates", () => { + assert.equal(editDistance("auth-rewrite", "auth-rewrte"), 1); + assert.equal(editDistance("abc", "abc"), 0); + }); +}); diff --git a/packages/core/src/format.ts b/packages/core/src/format.ts index 2ab3489..85f5f3f 100644 --- a/packages/core/src/format.ts +++ b/packages/core/src/format.ts @@ -1,3 +1,13 @@ +/** + * Format a duration for user-facing output. + * + * Units are d / h / m / s only (no weeks). Days stay as `Nd` so multi-week + * spans remain unambiguous (e.g. 14d rather than 2w). + * + * Under 10 minutes, seconds are always shown for short-session precision + * (e.g. `5m 30s`). At or above 10 minutes, seconds are omitted unless + * they are the only non-zero unit. + */ export function formatDuration(ms: number | null | undefined): string { if (ms == null || !Number.isFinite(ms) || ms < 0) { return "unknown"; @@ -9,13 +19,17 @@ export function formatDuration(ms: number | null | undefined): string { const minutes = Math.floor((totalSeconds % 3600) / 60); const seconds = totalSeconds % 60; + const underTenMinutes = totalSeconds < 10 * 60; const parts: string[] = []; if (days > 0) parts.push(`${days}d`); if (hours > 0) parts.push(`${hours}h`); if (minutes > 0) parts.push(`${minutes}m`); - if (parts.length === 0 || (days === 0 && hours === 0 && minutes === 0)) { + if (seconds > 0 && (underTenMinutes || parts.length === 0)) { parts.push(`${seconds}s`); } + if (parts.length === 0) { + parts.push("0s"); + } return parts.join(" "); } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0ac8c8d..42a5e91 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -2,8 +2,11 @@ export * from "./types.js"; export * from "./clock.js"; export * from "./format.js"; export * from "./duration.js"; +export * from "./config.js"; export * from "./store.js"; export * from "./effort.js"; export * from "./session.js"; export * from "./brief.js"; export * from "./ids.js"; +export * from "./doctor.js"; +export * from "./errors.js"; diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index d119554..19e25be 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -16,18 +16,50 @@ export function getActiveSession(store: StoreData): Session | null { return store.sessions.find((s) => s.id === store.activeSessionId) ?? null; } +export function sessionStatus( + store: StoreData, + now: Date = new Date(), +): { + session: Session | null; + effort: Effort | null; + ageMs: number | null; +} { + const session = getActiveSession(store); + if (!session) { + return { session: null, effort: null, ageMs: null }; + } + const effort = store.efforts.find((e) => e.id === session.effortId) ?? null; + const age = ageMs(session.startedAt, now); + return { + session, + effort, + ageMs: Number.isFinite(age) ? age : null, + }; +} + export function startSession( store: StoreData, effortNameOrId?: string, now: Date = new Date(), -): { store: StoreData; session: Session; effort: Effort } { - if (store.activeSessionId) { - throw new Error("A session is already open. End it with `wallclock session end` first."); + opts: { force?: boolean } = {}, +): { store: StoreData; session: Session; effort: Effort; forcedEnd?: Session } { + let working = store; + let forcedEnd: Session | undefined; + + if (working.activeSessionId) { + if (!opts.force) { + throw new Error( + "A session is already open. End it with `wallclock session end` first, or use `wallclock session start --force`.", + ); + } + const ended = endSession(working, now); + working = ended.store; + forcedEnd = ended.session; } - let effortId = store.activeEffortId; + let effortId = working.activeEffortId; if (effortNameOrId) { - const found = findEffort(store, effortNameOrId); + const found = findEffort(working, effortNameOrId); if (!found) { throw new Error(`Effort not found: ${effortNameOrId}`); } @@ -38,7 +70,7 @@ export function startSession( throw new Error("No active effort. Start one with `wallclock effort start `."); } - const effort = getEffortOrThrow(store, effortId); + const effort = getEffortOrThrow(working, effortId); const session: Session = { id: newId("ses"), effortId: effort.id, @@ -53,14 +85,14 @@ export function startSession( }; const next: StoreData = { - ...store, - efforts: store.efforts.map((e) => (e.id === effort.id ? updatedEffort : e)), - sessions: [...store.sessions, session], + ...working, + efforts: working.efforts.map((e) => (e.id === effort.id ? updatedEffort : e)), + sessions: [...working.sessions, session], activeSessionId: session.id, activeEffortId: effort.id, }; - return { store: next, session, effort: updatedEffort }; + return { store: next, session, effort: updatedEffort, forcedEnd }; } export function endSession( @@ -98,10 +130,16 @@ export function endSession( return { store: next, session: closed, effort: updatedEffort, durationMs: duration }; } +export interface TimelineOptions { + limit?: number; + effortName?: string; + now?: Date; +} + export function timeline( store: StoreData, - limit = 20, - now: Date = new Date(), + limitOrOpts: number | TimelineOptions = 20, + nowArg: Date = new Date(), ): Array<{ kind: "session"; id: string; @@ -110,10 +148,24 @@ export function timeline( endedAt: string | null; durationMs: number | null; }> { + const opts: TimelineOptions = + typeof limitOrOpts === "number" + ? { limit: limitOrOpts, now: nowArg } + : limitOrOpts; + const limit = opts.limit ?? 20; + const now = opts.now ?? nowArg; + const filterEffort = opts.effortName + ? findEffort(store, opts.effortName) + : undefined; + if (opts.effortName && !filterEffort) { + throw new Error(`Effort not found: ${opts.effortName}`); + } + const effortName = (id: string) => store.efforts.find((e) => e.id === id)?.name ?? "unknown"; return [...store.sessions] + .filter((s) => (filterEffort ? s.effortId === filterEffort.id : true)) .sort((a, b) => Date.parse(b.startedAt) - Date.parse(a.startedAt)) .slice(0, limit) .map((s) => { @@ -125,6 +177,7 @@ export function timeline( durationMs = Math.max(0, end - start); } } else if (Number.isFinite(start)) { + // Open rows always show live age relative to `now`. durationMs = Math.max(0, now.getTime() - start); } return { diff --git a/packages/core/src/store.test.ts b/packages/core/src/store.test.ts index 68e3cfe..b35f0e5 100644 --- a/packages/core/src/store.test.ts +++ b/packages/core/src/store.test.ts @@ -1,10 +1,22 @@ import assert from "node:assert/strict"; -import { mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { mkdtempSync, rmSync, statSync, writeFileSync, existsSync } 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, getStoreFilePath } from "./store.js"; +import { + initStore, + loadStore, + saveStore, + emptyStore, + getStoreFilePath, + backupStore, + restoreStore, + withStoreLock, + StoreLockError, + updateStore, +} from "./store.js"; import { startEffort } from "./effort.js"; +import { runDoctor } from "./doctor.js"; describe("store", () => { const dir = mkdtempSync(join(tmpdir(), "agent-wallclock-")); @@ -44,4 +56,77 @@ describe("store", () => { rmSync(badDir, { recursive: true, force: true }); } }); + + it("repairs corrupt store by quarantine", () => { + const badDir = mkdtempSync(join(tmpdir(), "agent-wallclock-repair-")); + try { + initStore(badDir); + writeFileSync(getStoreFilePath(badDir), "{not-json", "utf8"); + const repaired = loadStore(badDir, { repair: true }); + assert.equal(repaired.efforts.length, 0); + assert.ok(existsSync(getStoreFilePath(badDir))); + } finally { + rmSync(badDir, { recursive: true, force: true }); + } + }); + + it("backs up and restores", () => { + const bdir = mkdtempSync(join(tmpdir(), "agent-wallclock-bak-")); + try { + let store = emptyStore(); + ({ store } = startEffort(store, "bak", new Date("2026-01-01T12:00:00.000Z"))); + saveStore(store, bdir); + const dest = backupStore(bdir); + assert.ok(existsSync(dest)); + saveStore(emptyStore(), bdir); + const restored = restoreStore(dest, bdir); + assert.equal(restored.efforts[0]?.name, "bak"); + } finally { + rmSync(bdir, { recursive: true, force: true }); + } + }); + + it("strict lock throws when held", () => { + if (process.platform === "win32") return; + const ldir = mkdtempSync(join(tmpdir(), "agent-wallclock-lock-")); + try { + initStore(ldir); + withStoreLock(ldir, () => { + assert.throws( + () => + withStoreLock( + ldir, + () => "nested", + { timeoutMs: 50, strict: true }, + ), + (err: unknown) => err instanceof StoreLockError, + ); + }); + } finally { + rmSync(ldir, { recursive: true, force: true }); + } + }); + + it("updateStore mutates under lock", () => { + const udir = mkdtempSync(join(tmpdir(), "agent-wallclock-upd-")); + try { + initStore(udir); + const next = updateStore(udir, (s) => startEffort(s, "x").store); + assert.equal(next.efforts[0]?.name, "x"); + } finally { + rmSync(udir, { recursive: true, force: true }); + } + }); + + it("doctor validates store", () => { + const ddir = mkdtempSync(join(tmpdir(), "agent-wallclock-doc-")); + try { + initStore(ddir); + const result = runDoctor({ storeDir: ddir }); + assert.ok(result.ok); + assert.ok(result.checks.some((c) => c.id === "store-load" && c.ok)); + } finally { + rmSync(ddir, { recursive: true, force: true }); + } + }); }); diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 620b174..25d6df1 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -1,6 +1,7 @@ import { chmodSync, closeSync, + copyFileSync, existsSync, mkdirSync, openSync, @@ -11,15 +12,31 @@ import { } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; -import type { StoreData } from "./types.js"; +import type { Effort, StoreData, StoreVersion } from "./types.js"; export const STORE_DIRNAME = ".agent-wallclock"; export const STORE_FILENAME = "store.json"; export const STORE_LOCK_FILENAME = "store.lock"; +export const CURRENT_STORE_VERSION: StoreVersion = 1; +export const TARGET_STORE_VERSION: StoreVersion = 2; const DIR_MODE = 0o700; const FILE_MODE = 0o600; +export class StoreLockError extends Error { + constructor(message: string) { + super(message); + this.name = "StoreLockError"; + } +} + +export class StoreCorruptError extends Error { + constructor(message: string) { + super(message); + this.name = "StoreCorruptError"; + } +} + export function emptyStore(): StoreData { return { version: 1, @@ -62,17 +79,38 @@ function isProcessAlive(pid: number): boolean { } } +function sleepSync(ms: number): void { + const waitUntil = Date.now() + ms; + while (Date.now() < waitUntil) { + // brief spin + } +} + +export interface LockOptions { + /** Max wait for lock acquisition (ms). Default 2000. */ + timeoutMs?: number; + /** If true, throw StoreLockError instead of proceeding unlocked. Default false for compat. */ + strict?: boolean; +} + /** * 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). + * Stale locks (dead pid) are cleared. On timeout: + * - strict=false (default): warn to stderr and proceed (last-write-wins fallback) + * - strict=true: throw StoreLockError */ -export function withStoreLock(storeDir: string, fn: () => T): T { +export function withStoreLock( + storeDir: string, + fn: () => T, + opts: LockOptions = {}, +): T { ensureStoreDir(storeDir); const lockPath = getStoreLockPath(storeDir); - const deadline = Date.now() + 1000; + const timeoutMs = opts.timeoutMs ?? 2000; + const deadline = Date.now() + timeoutMs; let fd: number | null = null; let acquired = false; + let waitMs = 25; while (!acquired && Date.now() < deadline) { try { @@ -100,13 +138,23 @@ export function withStoreLock(storeDir: string, fn: () => T): T { } continue; } - const waitUntil = Date.now() + 25; - while (Date.now() < waitUntil) { - // brief spin while another process holds the lock - } + sleepSync(waitMs); + waitMs = Math.min(waitMs * 2, 200); } } + if (!acquired && opts.strict) { + throw new StoreLockError( + `Could not acquire store lock at ${lockPath} within ${timeoutMs}ms. Another wallclock process may be writing. Retry shortly.`, + ); + } + + if (!acquired) { + console.error( + `warning: store lock busy at ${lockPath}; proceeding without exclusive lock (last-write-wins). Set AGENT_WALLCLOCK_LOCK_STRICT=1 to fail instead.`, + ); + } + try { return fn(); } finally { @@ -125,7 +173,42 @@ export function withStoreLock(storeDir: string, fn: () => T): T { } } -export function loadStore(storeDir: string = getDefaultStoreDir()): StoreData { +/** + * Normalize on-disk store to in-memory shape. + * v1 → keep as v1; v2 fields (archived) already optional on Effort. + * Future writers may bump version to 2 after ensuring all readers understand it. + */ +export function migrateStore(raw: StoreData): StoreData { + const version = raw.version === 2 ? 2 : 1; + const efforts: Effort[] = (raw.efforts ?? []).map((e) => ({ + id: e.id, + name: e.name, + startedAt: e.startedAt, + totalMs: e.totalMs ?? 0, + sessionCount: e.sessionCount ?? 0, + lastActivityAt: e.lastActivityAt ?? null, + ...(e.archived ? { archived: true } : {}), + })); + return { + version, + efforts, + sessions: raw.sessions ?? [], + activeSessionId: raw.activeSessionId ?? null, + activeEffortId: raw.activeEffortId ?? null, + }; +} + +/** Documented migration path: bump in-memory version when archived flags appear. */ +export function ensureStoreV2(store: StoreData): StoreData { + const needsV2 = store.efforts.some((e) => e.archived === true) || store.version === 2; + if (!needsV2) return store; + return { ...store, version: 2 }; +} + +export function loadStore( + storeDir: string = getDefaultStoreDir(), + opts: { repair?: boolean } = {}, +): StoreData { const file = getStoreFilePath(storeDir); if (!existsSync(file)) { return emptyStore(); @@ -145,47 +228,89 @@ export function loadStore(storeDir: string = getDefaultStoreDir()): StoreData { 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 (opts.repair) { + return quarantineAndReset(storeDir, file, "corrupt JSON"); + } + throw new StoreCorruptError( + `Corrupt Agent Wallclock store at ${file}. Fix the JSON, delete the file, or run \`wallclock doctor --repair\`, then \`wallclock init\`.`, ); } if ( typeof parsed !== "object" || parsed === null || - (parsed as StoreData).version !== 1 || + !((parsed as StoreData).version === 1 || (parsed as StoreData).version === 2) || !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\`.`, + if (opts.repair) { + return quarantineAndReset(storeDir, file, "invalid schema"); + } + throw new StoreCorruptError( + `Invalid Agent Wallclock store at ${file} (expected version 1 or 2 with efforts/sessions arrays). Fix, delete, or run \`wallclock doctor --repair\`.`, ); } - const data = parsed as StoreData; - return { - version: 1, - efforts: data.efforts, - sessions: data.sessions, - activeSessionId: data.activeSessionId ?? null, - activeEffortId: data.activeEffortId ?? null, - }; + return migrateStore(parsed as StoreData); +} + +function quarantineAndReset( + storeDir: string, + file: string, + reason: string, +): StoreData { + const stamp = new Date().toISOString().replace(/[:.]/g, "-"); + const quarantine = join(storeDir, `store.corrupt.${stamp}.json`); + try { + renameSync(file, quarantine); + } catch { + try { + copyFileSync(file, quarantine); + unlinkSync(file); + } catch { + // ignore + } + } + console.error( + `warning: quarantined bad store (${reason}) to ${quarantine}; starting empty store`, + ); + const data = emptyStore(); + saveStore(data, storeDir); + return data; } -export function saveStore(data: StoreData, storeDir: string = getDefaultStoreDir()): void { +export function saveStore( + data: StoreData, + storeDir: string = getDefaultStoreDir(), + opts: { retries?: number } = {}, +): void { ensureStoreDir(storeDir); const file = getStoreFilePath(storeDir); const tmp = `${file}.tmp`; - writeFileSync(tmp, `${JSON.stringify(data, null, 2)}\n`, { - encoding: "utf8", - mode: FILE_MODE, - }); - renameSync(tmp, file); - try { - chmodSync(file, FILE_MODE); - } catch { - // Best-effort. + const retries = opts.retries ?? 3; + const payload = `${JSON.stringify(ensureStoreV2(data), null, 2)}\n`; + + let lastErr: unknown; + for (let attempt = 0; attempt < retries; attempt++) { + try { + writeFileSync(tmp, payload, { + encoding: "utf8", + mode: FILE_MODE, + }); + renameSync(tmp, file); + try { + chmodSync(file, FILE_MODE); + } catch { + // Best-effort. + } + return; + } catch (err) { + lastErr = err; + sleepSync(25 * (attempt + 1)); + } } + const message = lastErr instanceof Error ? lastErr.message : String(lastErr); + throw new Error(`Failed to save Agent Wallclock store at ${file}: ${message}`); } /** @@ -195,11 +320,16 @@ export function updateStore( storeDir: string, updater: (store: StoreData) => StoreData, ): StoreData { - return withStoreLock(storeDir, () => { - const next = updater(loadStore(storeDir)); - saveStore(next, storeDir); - return next; - }); + const strict = process.env.AGENT_WALLCLOCK_LOCK_STRICT === "1"; + return withStoreLock( + storeDir, + () => { + const next = updater(loadStore(storeDir)); + saveStore(next, storeDir); + return next; + }, + { strict }, + ); } export function initStore(storeDir: string = getDefaultStoreDir()): StoreData { @@ -214,3 +344,63 @@ export function initStore(storeDir: string = getDefaultStoreDir()): StoreData { return data; }); } + +export function backupStore( + storeDir: string = getDefaultStoreDir(), + destPath?: string, +): string { + ensureStoreDir(storeDir); + const file = getStoreFilePath(storeDir); + if (!existsSync(file)) { + const data = emptyStore(); + saveStore(data, storeDir); + } + const stamp = new Date().toISOString().replace(/[:.]/g, "-"); + const uniq = `${stamp}-${process.hrtime.bigint().toString(36)}`; + const dest = destPath ?? join(storeDir, `store.backup.${uniq}.json`); + copyFileSync(getStoreFilePath(storeDir), dest); + try { + chmodSync(dest, FILE_MODE); + } catch { + // ignore + } + return dest; +} + +export function restoreStore( + backupPath: string, + storeDir: string = getDefaultStoreDir(), +): StoreData { + if (!existsSync(backupPath)) { + throw new Error(`Backup not found: ${backupPath}`); + } + ensureStoreDir(storeDir); + // Validate backup before replacing. + const raw = readFileSync(backupPath, "utf8"); + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new StoreCorruptError(`Backup is not valid JSON: ${backupPath}`); + } + if ( + typeof parsed !== "object" || + parsed === null || + !Array.isArray((parsed as StoreData).efforts) || + !Array.isArray((parsed as StoreData).sessions) + ) { + throw new StoreCorruptError(`Backup has invalid store schema: ${backupPath}`); + } + // Snapshot current before overwrite. + const current = getStoreFilePath(storeDir); + if (existsSync(current)) { + backupStore(storeDir); + } + copyFileSync(backupPath, current); + try { + chmodSync(current, FILE_MODE); + } catch { + // ignore + } + return loadStore(storeDir); +} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 2ee1933..bd05847 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -5,6 +5,8 @@ export interface Effort { totalMs: number; sessionCount: number; lastActivityAt: string | null; + /** Soft-hidden from default list views; retained in store. */ + archived?: boolean; } export interface Session { @@ -14,8 +16,15 @@ export interface Session { endedAt: string | null; } +/** + * Store schema version 1 (current on-disk format). + * Version 2 migration path is prepared for archived flags and future fields; + * loaders accept v1 and normalize to the in-memory shape. + */ +export type StoreVersion = 1 | 2; + export interface StoreData { - version: 1; + version: StoreVersion; efforts: Effort[]; sessions: Session[]; activeSessionId: string | null; @@ -42,4 +51,12 @@ export interface BriefingInput { effortAgeMs: number | null; effortTotalMs: number | null; openSessionWarn: boolean; + openSessionCapNote: boolean; +} + +export interface BriefingOptions { + staleAfterMs?: number; + openSessionWarnAfterMs?: number; + openSessionSoftCapMs?: number; + nowDate?: Date; } diff --git a/packages/mcp/.npmignore b/packages/mcp/.npmignore new file mode 100644 index 0000000..7489f28 --- /dev/null +++ b/packages/mcp/.npmignore @@ -0,0 +1,4 @@ +*.test.js +*.test.d.ts +*.test.js.map +*.tsbuildinfo diff --git a/packages/mcp/README.md b/packages/mcp/README.md new file mode 100644 index 0000000..2d463e6 --- /dev/null +++ b/packages/mcp/README.md @@ -0,0 +1,61 @@ +# @agent-wallclock/mcp + +Local **stdio** MCP server exposing Agent Wallclock clock, briefing, and ledger tools. + +**License:** Apache-2.0 + +## Run + +After build: + +```bash +node /ABSOLUTE/PATH/TO/agent-wallclock/packages/mcp/dist/server.js +``` + +Prefer generating host config from the CLI: + +```bash +wallclock mcp-config --print cursor --check +wallclock mcp-config --print claude --check +wallclock mcp-config --print vscode --check +``` + +See [`adapters/mcp/README.md`](../../adapters/mcp/README.md). + +## Tools + +| Tool | Writes | Description | +|------|--------|-------------| +| `get_now` | no | System clock | +| `get_briefing` | no | Full Temporal Briefing (check freshness) | +| `list_efforts` | no | Named efforts + logged time | +| `get_session_status` | no | Open session age or none | +| `get_timeline` | no | Recent sessions (`limit`, `effort`) | +| `start_effort` | yes* | Create/select active effort | +| `log_session` | yes* | `start` / `end` / `manual` session actions | + +\*Requires `AGENT_WALLCLOCK_WRITES=1` in server `env`. + +## Environment + +| Variable | Purpose | +|----------|---------| +| `AGENT_WALLCLOCK_HOME` | Store directory | +| `AGENT_WALLCLOCK_WRITES=1` | Enable mutating tools | +| `AGENT_WALLCLOCK_STALE_AFTER_MS` | Briefing freshness (via core) | + +No network I/O. Trust boundary: host process can read store; with writes enabled, can mutate ledger. + +## Dependencies + +- `@agent-wallclock/core` +- `@modelcontextprotocol/sdk` (MIT) +- `zod` (MIT) — see [NOTICE](../../NOTICE) + +## Build + +```bash +npm run build -w @agent-wallclock/mcp +``` + +Smoke-tested via root `npm run smoke`. diff --git a/packages/mcp/package.json b/packages/mcp/package.json index c79a145..aa433c7 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -8,16 +8,22 @@ "agent-wallclock-mcp": "./dist/server.js" }, "files": [ - "dist" + "dist", + "README.md" ], "scripts": { - "build": "tsc -p tsconfig.json" + "build": "tsc -p tsconfig.json", + "lint": "tsc -p tsconfig.json --noEmit", + "prepublishOnly": "npm run build" }, "dependencies": { - "@agent-wallclock/core": "0.1.0", + "@agent-wallclock/core": "*", "@modelcontextprotocol/sdk": "^1.12.1", "zod": "^3.24.2" }, + "engines": { + "node": ">=20" + }, "devDependencies": { "@types/node": "^22.13.10", "typescript": "^5.8.2" diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index b6c2877..8fbfaa6 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -13,22 +13,31 @@ import { listEfforts, startSession, endSession, + sessionStatus, + timeline, renderBriefing, formatDuration, logManualDuration, parseDuration, effortStatus, + loadConfig, } 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) { +function textResult(text: string, isError = false) { return { content: [{ type: "text" as const, text }], + ...(isError ? { isError: true } : {}), }; } +function errorResult(err: unknown) { + const message = err instanceof Error ? err.message : String(err); + return textResult(message, true); +} + function requireWrites(): void { if (!WRITES_ENABLED) { throw new Error( @@ -47,16 +56,20 @@ server.tool( "Return the current local wall-clock time, timezone, weekday, and ISO UTC timestamp from the system clock.", {}, async () => { - const now = getNow(); - return textResult( - [ - `local_date=${now.localDate}`, - `local_time=${now.localTime}`, - `weekday=${now.weekday}`, - `timezone=${now.timezone}`, - `iso_utc=${now.iso}`, - ].join("\n"), - ); + try { + const now = getNow(); + return textResult( + [ + `local_date=${now.localDate}`, + `local_time=${now.localTime}`, + `weekday=${now.weekday}`, + `timezone=${now.timezone}`, + `iso_utc=${now.iso}`, + ].join("\n"), + ); + } catch (err) { + return errorResult(err); + } }, ); @@ -65,8 +78,12 @@ server.tool( "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); - return textResult(renderBriefing(store)); + try { + const store = loadStore(STORE_DIR); + return textResult(renderBriefing(store)); + } catch (err) { + return errorResult(err); + } }, ); @@ -75,17 +92,84 @@ server.tool( "List named efforts and their logged work time from the local Agent Wallclock store.", {}, async () => { - const store = loadStore(STORE_DIR); - const efforts = listEfforts(store); - if (efforts.length === 0) { - return textResult("No efforts recorded."); + try { + const store = loadStore(STORE_DIR); + const efforts = listEfforts(store); + if (efforts.length === 0) { + return textResult("No efforts recorded."); + } + const lines = efforts.map((e) => { + const active = store.activeEffortId === e.id ? "active" : "inactive"; + 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")); + } catch (err) { + return errorResult(err); + } + }, +); + +server.tool( + "get_session_status", + "Return the open work session status and live age, or report that none is open.", + {}, + async () => { + try { + const store = loadStore(STORE_DIR); + const st = sessionStatus(store); + if (!st.session) { + return textResult("status=none"); + } + const cfg = loadConfig(); + const lines = [ + `status=open`, + `id=${st.session.id}`, + `effort=${st.effort?.name ?? "unknown"}`, + `started_utc=${st.session.startedAt}`, + `age=${formatDuration(st.ageMs)}`, + `age_ms=${st.ageMs ?? 0}`, + ]; + if (st.ageMs != null && st.ageMs >= cfg.openSessionWarnAfterMs) { + lines.push(`warn=open-session-age`); + } + if (st.ageMs != null && st.ageMs >= cfg.openSessionSoftCapMs) { + lines.push(`soft_cap=exceeded`); + } + return textResult(lines.join("\n")); + } catch (err) { + return errorResult(err); + } + }, +); + +server.tool( + "get_timeline", + "Return recent session timeline rows with live age for open sessions. Optional effort filter and limit.", + { + limit: z.number().int().positive().optional().describe("Max rows (default from config)"), + effort: z.string().optional().describe("Filter by effort name or id"), + }, + async ({ limit, effort }) => { + try { + const store = loadStore(STORE_DIR); + const cfg = loadConfig(); + const rows = timeline(store, { + limit: limit ?? cfg.timelineDefaultLimit, + effortName: effort, + }); + if (rows.length === 0) { + return textResult("No sessions recorded."); + } + const lines = rows.map((r) => { + const dur = r.durationMs != null ? formatDuration(r.durationMs) : "unknown"; + const state = r.endedAt ? "closed" : "open"; + return `${r.startedAt} | ${r.effortName} | ${dur} | ${state}`; + }); + return textResult(lines.join("\n")); + } catch (err) { + return errorResult(err); } - const lines = efforts.map((e) => { - const active = store.activeEffortId === e.id ? "active" : "inactive"; - 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")); }, ); @@ -94,28 +178,35 @@ server.tool( "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 }) => { - 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( - created - ? `Created effort "${effortName}" (${effortId})` - : `Selected existing effort "${effortName}" (${effortId})`, - ); + try { + requireWrites(); + let created = false; + let effortName = ""; + let effortId = ""; + let near = ""; + updateStore(STORE_DIR, (store) => { + const result = startEffort(store, name); + created = result.created; + effortName = result.effort.name; + effortId = result.effort.id; + near = result.nearDuplicate?.name ?? ""; + return result.store; + }); + const nearNote = near ? ` (similar to existing "${near}")` : ""; + return textResult( + created + ? `Created effort "${effortName}" (${effortId})${nearNote}` + : `Selected existing effort "${effortName}" (${effortId})`, + ); + } catch (err) { + return errorResult(err); + } }, ); 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. Requires AGENT_WALLCLOCK_WRITES=1.", + "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. Durations use core parseDuration (e.g. 30m, 2h).", { action: z.enum(["start", "end", "manual"]), effort: z.string().optional().describe("Effort name or id (for start/manual)"), @@ -123,51 +214,59 @@ server.tool( .string() .optional() .describe("For manual only: duration like 30m, 2h, 90s"), + force: z + .boolean() + .optional() + .describe("For start: end open session and restart"), }, - async ({ action, effort, duration }) => { - requireWrites(); + async ({ action, effort, duration, force }) => { + try { + requireWrites(); - if (action === "start") { - 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 === "start") { + let effortName = ""; + let sessionId = ""; + updateStore(STORE_DIR, (store) => { + const result = startSession(store, effort, new Date(), { force: Boolean(force) }); + effortName = result.effort.name; + sessionId = result.session.id; + return result.store; + }); + return textResult(`Session open on "${effortName}" (${sessionId})`); + } + + if (action === "end") { + 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(durationMs)}. Effort total=${formatDuration(totalMs)}`, + ); + } - if (action === "end") { - let durationMs = 0; + if (!effort || !duration) { + throw new Error("manual log_session requires effort and duration (e.g. 30m)"); + } + const ms = parseDuration(duration); + let effortName = ""; let totalMs = 0; updateStore(STORE_DIR, (store) => { - const result = endSession(store); - durationMs = result.durationMs; + const result = logManualDuration(store, effort, ms); + effortName = result.effort.name; totalMs = result.effort.totalMs; return result.store; }); return textResult( - `Session closed. Duration=${formatDuration(durationMs)}. Effort total=${formatDuration(totalMs)}`, + `Logged ${formatDuration(ms)} on "${effortName}". Total=${formatDuration(totalMs)}`, ); + } catch (err) { + return errorResult(err); } - - if (!effort || !duration) { - throw new Error("manual log_session requires effort and duration (e.g. 30m)"); - } - 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 "${effortName}". Total=${formatDuration(totalMs)}`, - ); }, ); @@ -186,3 +285,5 @@ if (isDirectRun) { process.exit(1); }); } + +export { server }; diff --git a/scripts/leak-grep.mjs b/scripts/leak-grep.mjs new file mode 100644 index 0000000..9447eac --- /dev/null +++ b/scripts/leak-grep.mjs @@ -0,0 +1,59 @@ +#!/usr/bin/env node +/** + * Scan tracked files for accidental secret or personal-path leaks. + * Skips workflow YAML and this script (they document match patterns). + */ +import { execSync } from "node:child_process"; +import { readFileSync } from "node:fs"; + +const SKIP_FILES = new Set([ + ".github/workflows/ci.yml", + "scripts/leak-grep.mjs", +]); + +const PATTERNS = [ + { id: "home-path", re: /\/Users\/[A-Za-z0-9._-]+\// }, + { id: "openai-key", re: /\bsk-[A-Za-z0-9]{20,}\b/ }, + { id: "github-token", re: /\bghp_[A-Za-z0-9]{20,}\b/ }, + { id: "slack-token", re: /\bxoxb-[A-Za-z0-9-]{20,}\b/ }, + { id: "private-key", re: /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/ }, +]; + +function trackedFiles() { + const out = execSync("git ls-files -z", { encoding: "buffer" }); + return out + .toString("utf8") + .split("\0") + .filter(Boolean) + .filter((f) => !SKIP_FILES.has(f)); +} + +const hits = []; + +for (const file of trackedFiles()) { + let text; + try { + text = readFileSync(file, "utf8"); + } catch { + continue; + } + const lines = text.split("\n"); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + for (const { id, re } of PATTERNS) { + if (re.test(line)) { + hits.push({ file, line: i + 1, pattern: id, snippet: line.trim().slice(0, 120) }); + } + } + } +} + +if (hits.length > 0) { + console.error("leak-grep: possible secrets or personal paths in tracked files:\n"); + for (const h of hits) { + console.error(` ${h.file}:${h.line} [${h.pattern}] ${h.snippet}`); + } + process.exit(1); +} + +console.log("leak-grep OK"); diff --git a/scripts/mcp-smoke.mjs b/scripts/mcp-smoke.mjs index 0a27fb8..a957cbf 100644 --- a/scripts/mcp-smoke.mjs +++ b/scripts/mcp-smoke.mjs @@ -18,9 +18,20 @@ import { } from "../packages/core/dist/index.js"; const root = fileURLToPath(new URL("..", import.meta.url)); +const serverPath = join(root, "packages", "mcp", "dist", "server.js"); const home = mkdtempSync(join(tmpdir(), "wallclock-mcp-smoke-")); process.env.AGENT_WALLCLOCK_HOME = home; +const EXPECTED_TOOLS = [ + "get_now", + "get_briefing", + "list_efforts", + "get_session_status", + "get_timeline", + "start_effort", + "log_session", +]; + function send(proc, msg) { proc.stdin.write(`${JSON.stringify(msg)}\n`); } @@ -43,7 +54,7 @@ function readMessages(proc, count, timeoutMs = 8000) { if (!line) continue; try { messages.push(JSON.parse(line)); - } catch (err) { + } catch { cleanup(); reject(new Error(`invalid JSON from MCP: ${line}`)); return; @@ -65,8 +76,21 @@ function readMessages(proc, count, timeoutMs = 8000) { }); } -async function mcpProtocolSmoke() { - const serverPath = join(root, "packages/mcp/dist/server.js"); +async function callTool(proc, id, name, args = {}) { + send(proc, { + jsonrpc: "2.0", + id, + method: "tools/call", + params: { name, arguments: args }, + }); + const [resp] = await readMessages(proc, 1); + if (resp.error) { + throw new Error(`${name} failed: ${JSON.stringify(resp.error)}`); + } + return resp.result; +} + +async function mcpProtocolSmoke({ writesEnabled = false } = {}) { if (!existsSync(serverPath)) { throw new Error("MCP server build missing"); } @@ -75,8 +99,7 @@ async function mcpProtocolSmoke() { env: { ...process.env, AGENT_WALLCLOCK_HOME: home, - // writes off by default — exercise read tools - AGENT_WALLCLOCK_WRITES: "0", + AGENT_WALLCLOCK_WRITES: writesEnabled ? "1" : "0", }, stdio: ["pipe", "pipe", "pipe"], }); @@ -98,10 +121,7 @@ async function mcpProtocolSmoke() { throw new Error("initialize missing serverInfo"); } - send(proc, { - jsonrpc: "2.0", - method: "notifications/initialized", - }); + send(proc, { jsonrpc: "2.0", method: "notifications/initialized" }); send(proc, { jsonrpc: "2.0", @@ -110,57 +130,61 @@ async function mcpProtocolSmoke() { 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}`); - } + const names = (toolsResp.result?.tools ?? []).map((t) => t.name).sort(); + const expected = [...EXPECTED_TOOLS].sort(); + if (names.length !== expected.length || names.some((n, i) => n !== expected[i])) { + throw new Error(`tools/list mismatch.\n got: ${names.join(", ")}\n expected: ${expected.join(", ")}`); } - 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 ?? ""; + const nowResult = await callTool(proc, 3, "get_now"); + const nowText = nowResult?.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 ?? ""; + const briefResult = await callTool(proc, 4, "get_briefing"); + const briefText = briefResult?.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}`); + const sessionResult = await callTool(proc, 5, "get_session_status"); + const sessionText = sessionResult?.content?.[0]?.text ?? ""; + if (!/status=(open|none)/.test(sessionText)) { + throw new Error(`get_session_status unexpected: ${sessionText}`); + } + + const timelineResult = await callTool(proc, 6, "get_timeline", { limit: 5 }); + const timelineText = timelineResult?.content?.[0]?.text ?? ""; + if (!timelineText || timelineText.includes("No sessions")) { + throw new Error(`get_timeline unexpected: ${timelineText}`); + } + + if (writesEnabled) { + const writeOk = await callTool(proc, 7, "start_effort", { name: "mcp-write-test" }); + const writeOkText = writeOk?.content?.[0]?.text ?? ""; + if (writeOk?.isError || !/effort|Created|Selected/i.test(writeOkText)) { + throw new Error(`start_effort with writes enabled failed: ${writeOkText}`); + } + } else { + send(proc, { + jsonrpc: "2.0", + id: 7, + method: "tools/call", + params: { name: "start_effort", arguments: { name: "should-fail" } }, + }); + const [writeResp] = await readMessages(proc, 1); + const writeResult = writeResp.result; + if (!writeResult?.isError) { + throw new Error(`expected isError for write-denied start_effort, got: ${JSON.stringify(writeResult)}`); + } + const writeText = writeResult?.content?.[0]?.text ?? ""; + if (!/AGENT_WALLCLOCK_WRITES|disabled|writes/i.test(writeText)) { + throw new Error(`expected writes-disabled message, got: ${writeText}`); } } - console.log("MCP stdio protocol smoke OK"); + console.log(`MCP stdio protocol smoke OK (writes=${writesEnabled ? "1" : "0"})`); } finally { proc.kill("SIGTERM"); } @@ -187,18 +211,17 @@ try { ({ store } = logManualDuration(store, "docs-pass", 60_000)); saveStore(store, home); - const serverPath = new URL("../packages/mcp/dist/server.js", import.meta.url); if (!existsSync(serverPath)) { throw new Error("MCP server build missing"); } - // Import without starting stdio (server guards on direct-run). - await import(serverPath.href); + await import(new URL(serverPath, import.meta.url).href); console.log("MCP/core tool-path smoke OK"); console.log(`default store helper: ${getDefaultStoreDir() ? "ok" : "missing"}`); - await mcpProtocolSmoke(); + await mcpProtocolSmoke({ writesEnabled: false }); + await mcpProtocolSmoke({ writesEnabled: true }); } finally { rmSync(home, { recursive: true, force: true }); } diff --git a/scripts/perf-brief.mjs b/scripts/perf-brief.mjs new file mode 100644 index 0000000..dea1a61 --- /dev/null +++ b/scripts/perf-brief.mjs @@ -0,0 +1,48 @@ +#!/usr/bin/env node +/** + * Benchmark briefing generation on a large synthetic session history. + */ +import { performance } from "node:perf_hooks"; +import { renderBriefing } from "../packages/core/dist/index.js"; + +const SESSION_COUNT = Number(process.env.PERF_SESSION_COUNT ?? "2000"); + +function syntheticStore(sessionCount) { + const effortId = "eff-perf"; + const baseMs = Date.UTC(2026, 0, 1); + const efforts = [ + { + id: effortId, + name: "perf-effort", + startedAt: new Date(baseMs).toISOString(), + totalMs: sessionCount * 3_600_000, + sessionCount, + lastActivityAt: new Date(baseMs + sessionCount * 3_600_000).toISOString(), + }, + ]; + const sessions = []; + for (let i = 0; i < sessionCount; i++) { + const start = baseMs + i * 3_600_000; + sessions.push({ + id: `sess-${i}`, + effortId, + startedAt: new Date(start).toISOString(), + endedAt: new Date(start + 1_800_000).toISOString(), + }); + } + return { + version: 1, + efforts, + sessions, + activeSessionId: null, + activeEffortId: effortId, + }; +} + +const store = syntheticStore(SESSION_COUNT); +const start = performance.now(); +const text = renderBriefing(store); +const ms = performance.now() - start; + +console.log(`brief generation (${SESSION_COUNT} sessions): ${ms.toFixed(2)} ms`); +console.log(`output length: ${text.length} chars`); diff --git a/scripts/publish-dry-run.mjs b/scripts/publish-dry-run.mjs new file mode 100644 index 0000000..bf490b8 --- /dev/null +++ b/scripts/publish-dry-run.mjs @@ -0,0 +1,23 @@ +#!/usr/bin/env node +/** + * Dry-run npm publish for publishable workspace packages (no actual publish). + */ +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const root = fileURLToPath(new URL("..", import.meta.url)); +const packages = ["@agent-wallclock/cli", "@agent-wallclock/mcp"]; + +for (const pkg of packages) { + console.log(`\n--- npm publish --dry-run -w ${pkg} ---`); + const result = spawnSync("npm", ["publish", "--dry-run", "-w", pkg], { + cwd: root, + stdio: "inherit", + shell: process.platform === "win32", + }); + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +} + +console.log("\npublish dry-run OK"); diff --git a/scripts/qa-local.mjs b/scripts/qa-local.mjs new file mode 100644 index 0000000..b4377db --- /dev/null +++ b/scripts/qa-local.mjs @@ -0,0 +1,28 @@ +#!/usr/bin/env node +/** + * Local QA aggregator: unit tests, smoke scripts, and leak scan. + */ +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const root = fileURLToPath(new URL("..", import.meta.url)); + +function run(label, cmd, args) { + console.log(`\n--- ${label} ---`); + const result = spawnSync(cmd, args, { + cwd: root, + stdio: "inherit", + shell: process.platform === "win32", + }); + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +} + +run("test", "npm", ["test"]); +run("smoke", "npm", ["run", "smoke"]); +run("leak-grep", process.execPath, [ + fileURLToPath(new URL("./leak-grep.mjs", import.meta.url)), +]); + +console.log("\nqa:local OK"); diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs index 29b510c..825b395 100644 --- a/scripts/smoke.mjs +++ b/scripts/smoke.mjs @@ -5,7 +5,7 @@ import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; const root = fileURLToPath(new URL("..", import.meta.url)); -const bin = join(root, "packages/cli/dist/bin.js"); +const bin = join(root, "packages", "cli", "dist", "bin.js"); const home = mkdtempSync(join(tmpdir(), "wallclock-smoke-")); function run(args, { expectFail = false } = {}) { @@ -46,6 +46,33 @@ try { if (!brief.includes("Generated at:")) { throw new Error("brief missing freshness"); } + const briefJson = run(["brief", "--json"]); + const parsed = JSON.parse(briefJson); + if (!parsed.generatedAt || !parsed.now) { + throw new Error("brief --json missing expected fields"); + } + const version = run(["--version"]).trim(); + if (!/^\d+\.\d+\.\d+/.test(version)) { + throw new Error(`unexpected --version output: ${version}`); + } + const where = run(["where"]); + if (!where.includes("store_dir:") || !where.includes("mcp_server:")) { + throw new Error("where missing expected paths"); + } + const doctor = run(["doctor"]); + if (!doctor.includes("Doctor OK")) { + throw new Error("doctor did not pass"); + } + const sessionStatus = run(["session", "status"]); + if (!sessionStatus.includes("Status:") || !sessionStatus.includes("open")) { + throw new Error("session status missing open session"); + } + run(["effort", "start", "docs"]); + const effortList = run(["effort", "list", "--json"]); + const efforts = JSON.parse(effortList); + if (!Array.isArray(efforts) || efforts.length < 2) { + throw new Error("effort list --json expected multiple efforts"); + } const cfg = run(["mcp-config", "--print", "cursor"]); if (!cfg.includes("mcpServers") || !cfg.includes("server.js")) { throw new Error("mcp-config missing server path"); From b73753cdefbda5da73b1e4af57d5a8b9f34c6f28 Mon Sep 17 00:00:00 2001 From: TelivityAI Date: Fri, 31 Jul 2026 02:51:10 -0500 Subject: [PATCH 2/2] Fix CI lint order and use gitleaks CLI without org license. Build packages before tsc --noEmit so workspace types resolve, and run upstream gitleaks binary instead of the licensed GitHub Action. --- .github/workflows/ci.yml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a23b797..ed7fb01 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,7 @@ jobs: node-version: "22" cache: npm - run: npm install + - run: npm run build - run: npm run lint test: @@ -102,9 +103,10 @@ jobs: echo "no-telemetry OK" - run: npm audit --audit-level=high - name: gitleaks - uses: gitleaks/gitleaks-action@v2 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + curl -sSfL https://github.com/gitleaks/gitleaks/releases/download/v8.24.3/gitleaks_8.24.3_linux_x64.tar.gz | tar -xz gitleaks + ./gitleaks detect --source . --verbose --no-banner actionlint: runs-on: ubuntu-latest