● Zion Boggan zionboggan.com ↗

Add agent-time-ledger CLI and MCP server

3c9826d   Zion Boggan committed on Jun 19, 2026 (2 months ago)
.github/assets/cli-proof.svg +22 -0
@@ -0,0 +1,22 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="980" height="520" viewBox="0 0 980 520" role="img" aria-labelledby="title desc">
+ <title id="title">agent-time-ledger CLI proof</title>
+ <desc id="desc">A terminal-style visual showing atl commands for current time, sessions, marks, stale checks, and reports.</desc>
+ <rect width="980" height="520" fill="#101418"/>
+ <rect x="34" y="28" width="912" height="464" rx="8" fill="#151b21" stroke="#2d3944"/>
+ <circle cx="62" cy="54" r="6" fill="#ff5f57"/>
+ <circle cx="84" cy="54" r="6" fill="#ffbd2e"/>
+ <circle cx="106" cy="54" r="6" fill="#28c840"/>
+ <text x="132" y="60" fill="#95a3b3" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="15">atl local ledger demo</text>
+ <text x="58" y="104" fill="#7ee787" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="17">$ atl now --json</text>
+ <text x="78" y="132" fill="#c9d1d9" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="16">{ "timestamp": "2026-06-19T06:39:35Z", "confidence": "host_clock" }</text>
+ <text x="58" y="176" fill="#7ee787" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="17">$ atl session start --name build</text>
+ <text x="78" y="204" fill="#c9d1d9" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="16">started session sess_20260619T063935Z (build)</text>
+ <text x="58" y="248" fill="#7ee787" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="17">$ atl mark check ci --json</text>
+ <text x="78" y="276" fill="#c9d1d9" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="16">{ "name": "ci", "elapsed_human": "2h 4m 12s", "confidence": "wall_clock_fallback" }</text>
+ <text x="58" y="320" fill="#7ee787" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="17">$ atl stale --mark ci --ttl 30m --json</text>
+ <text x="78" y="348" fill="#c9d1d9" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="16">{ "stale": true, "elapsed_human": "2h 4m 12s" }</text>
+ <text x="58" y="392" fill="#7ee787" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="17">$ atl report today</text>
+ <text x="78" y="420" fill="#c9d1d9" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="16">report 2026-06-19: 5 events, 0 open marks, active_session=false</text>
+ <rect x="58" y="448" width="864" height="1" fill="#2d3944"/>
+ <text x="58" y="474" fill="#8b949e" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="14">Local-only state: state.json, events.jsonl, config.toml</text>
+</svg>
.github/assets/test-proof.svg +17 -0
@@ -0,0 +1,17 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="980" height="360" viewBox="0 0 980 360" role="img" aria-labelledby="title desc">
+ <title id="title">agent-time-ledger test proof</title>
+ <desc id="desc">A terminal-style visual showing go test, go vet, build, CLI smoke, and MCP smoke checks passing.</desc>
+ <rect width="980" height="360" fill="#0f1720"/>
+ <rect x="34" y="28" width="912" height="304" rx="8" fill="#151b21" stroke="#2d3944"/>
+ <circle cx="62" cy="54" r="6" fill="#ff5f57"/>
+ <circle cx="84" cy="54" r="6" fill="#ffbd2e"/>
+ <circle cx="106" cy="54" r="6" fill="#28c840"/>
+ <text x="132" y="60" fill="#95a3b3" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="15">verification</text>
+ <text x="58" y="108" fill="#7ee787" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="18">$ go test ./...</text>
+ <text x="78" y="138" fill="#c9d1d9" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="16">ok internal/clock duration and RFC3339 checks</text>
+ <text x="78" y="164" fill="#c9d1d9" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="16">ok internal/ledger sessions, marks, stale checks, JSONL persistence</text>
+ <text x="58" y="210" fill="#7ee787" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="18">$ go vet ./...</text>
+ <text x="78" y="240" fill="#c9d1d9" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="16">clean</text>
+ <text x="58" y="286" fill="#7ee787" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="18">$ atl serve-mcp</text>
+ <text x="78" y="316" fill="#c9d1d9" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="16">tools/list and time_now returned valid JSON-RPC responses</text>
+</svg>
.github/workflows/test.yml +29 -0
@@ -0,0 +1,29 @@
+name: Test
+
+on:
+ push:
+ branches: ["**"]
+ pull_request:
+ branches: ["main"]
+
+permissions:
+ contents: read
+
+jobs:
+ go:
+ name: Go
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up Go
+ uses: actions/setup-go@v5
+ with:
+ go-version: "1.22.x"
+
+ - name: Test
+ run: go test ./...
+
+ - name: Vet
+ run: go vet ./...
.gitignore +3 -0
@@ -0,0 +1,3 @@
+atl
+dist/
+coverage.out
LICENSE +21 -0
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Zion Boggan
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
README.md +101 -1
@@ -1,2 +1,102 @@
# agent-time-ledger
-Local-first elapsed-time ledger for software agents.
+
+[![Test](https://github.com/ZionBoggan/agent-time-ledger/actions/workflows/test.yml/badge.svg)](https://github.com/ZionBoggan/agent-time-ledger/actions/workflows/test.yml)
+
+![CLI proof](.github/assets/cli-proof.svg)
+
+`agent-time-ledger` is a small local-first tool that helps LLM agents stop guessing how much time has passed.
+
+Basic current-time tools already exist. The useful gap is elapsed time: an agent may say "10 minutes passed" because the conversation looks short, even when 2 hours actually passed while commands, CI jobs, renders, or external services were running. `atl` measures from the runtime clock and stores local marks, sessions, stale checks, and reports.
+
+The binary name is `atl`.
+
+## Scope
+
+- One local CLI binary.
+- No cloud service.
+- No telemetry.
+- No prompt capture.
+- No SaaS dashboard.
+- No token or cost tracking.
+- No natural-language date parsing.
+- No general observability platform.
+
+State is stored under `~/.agent-time-ledger` by default:
+
+- `state.json`
+- `events.jsonl`
+- `config.toml`
+
+For tests or isolated runs, set `ATL_HOME=/path/to/dir`.
+
+## Install
+
+```bash
+go install github.com/zionboggan/agent-time-ledger/cmd/atl@latest
+```
+
+From a local checkout:
+
+```bash
+go build -o atl ./cmd/atl
+```
+
+## CLI
+
+```bash
+atl now
+atl now --json
+
+atl session start --name build
+atl session status
+atl session status --json
+atl session end
+
+atl mark start ci
+atl mark check ci
+atl mark check ci --json
+atl mark list
+atl mark delete ci
+
+atl stale --timestamp 2026-06-19T06:30:00Z --ttl 30m
+atl stale --timestamp 2026-06-19T06:30:00Z --ttl 30m --json
+atl stale --mark ci --ttl 2h
+
+atl report today
+atl report --json
+
+atl serve-mcp
+```
+
+JSON responses include a `confidence` value. For v0.1, persisted elapsed calculations use wall-clock timestamps, so elapsed responses report `wall_clock_fallback`. Current host time reports `host_clock`.
+
+## MCP Tools
+
+`atl serve-mcp` runs a stdio MCP server exposing local-only tools:
+
+- `time_now`
+- `session_status`
+- `mark_start`
+- `mark_elapsed`
+- `stale_check`
+- `ledger_event`
+- `ledger_report`
+
+The MCP server only reads and writes local `atl` state. It does not run shell commands, read arbitrary files, access the network, or sync to a cloud service.
+
+## Verification
+
+![Test proof](.github/assets/test-proof.svg)
+
+Tests and repeatable checks are documented in [docs/verification.md](docs/verification.md). Pull requests run `go test ./...` and `go vet ./...` in GitHub Actions.
+
+## Agent Rule Block
+
+```text
+Use agent-time-ledger tools for time-sensitive claims.
+
+Do not estimate elapsed time from conversation text.
+Before saying how long something took, call session_status or mark_elapsed.
+Before relying on previous external observations, call stale_check.
+When starting a long-running task, call mark_start with a descriptive name.
+```
SECURITY.md +14 -0
@@ -0,0 +1,14 @@
+# Security
+
+`agent-time-ledger` is local-first by design.
+
+- No telemetry
+- No cloud sync
+- No prompt capture
+- No arbitrary file reads through MCP tools
+- No shell execution through MCP tools
+- No network access from the CLI or MCP server
+
+The default state directory is `~/.agent-time-ledger`. Set `ATL_HOME` to isolate state for testing.
+
+Please report security issues privately rather than opening a public issue with exploit details.
docs/verification.md +38 -0
@@ -0,0 +1,38 @@
+# Verification
+
+This repository includes a focused Go test suite plus a GitHub Actions workflow that runs on pushes and pull requests.
+
+## Local Checks
+
+The initial public branch was checked with:
+
+```text
+go test ./...
+go vet ./...
+go build -o /tmp/atl-smoke ./cmd/atl
+ATL_HOME="$(mktemp -d)" /tmp/atl-smoke now --json
+ATL_HOME="$(mktemp -d)" /tmp/atl-smoke serve-mcp
+```
+
+Covered behavior:
+
+- RFC3339 timestamp output
+- human duration formatting
+- TTL parsing for `15s`, `30m`, `2h`, and `1d`
+- stale checks before and after TTL expiry
+- missing mark errors
+- session status with no active session
+- persisted sessions and marks
+- valid JSONL audit events
+- MCP tool discovery and `time_now`
+
+## Public CI
+
+The workflow at `.github/workflows/test.yml` runs:
+
+```text
+go test ./...
+go vet ./...
+```
+
+That makes the tests visible on GitHub pull requests before merge.
go.mod +3 -0
@@ -0,0 +1,3 @@
+module github.com/zionboggan/agent-time-ledger
+
+go 1.19
internal/cli/cli.go +298 -0
@@ -0,0 +1,298 @@
+package cli
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "sort"
+ "strings"
+
+ "github.com/zionboggan/agent-time-ledger/internal/clock"
+ "github.com/zionboggan/agent-time-ledger/internal/ledger"
+ "github.com/zionboggan/agent-time-ledger/internal/mcp"
+ "github.com/zionboggan/agent-time-ledger/internal/state"
+)
+
+func Run(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
+ store, err := state.DefaultStore()
+ if err != nil {
+ fmt.Fprintln(stderr, err)
+ return 1
+ }
+ service := ledger.NewService(store)
+ if err := run(args, stdin, stdout, stderr, service); err != nil {
+ fmt.Fprintln(stderr, "atl:", err)
+ return 1
+ }
+ return 0
+}
+
+func run(args []string, stdin io.Reader, stdout, stderr io.Writer, service *ledger.Service) error {
+ if len(args) == 0 {
+ printUsage(stdout)
+ return nil
+ }
+
+ switch args[0] {
+ case "now":
+ jsonOut, err := parseJSONFlag(args[1:])
+ if err != nil {
+ return err
+ }
+ response := service.NowResponse()
+ if jsonOut {
+ return writeJSON(stdout, response)
+ }
+ fmt.Fprintln(stdout, response.Timestamp)
+ return nil
+ case "session":
+ return runSession(args[1:], stdout, service)
+ case "mark":
+ return runMark(args[1:], stdout, service)
+ case "stale":
+ return runStale(args[1:], stdout, service)
+ case "report":
+ return runReport(args[1:], stdout, service)
+ case "serve-mcp":
+ return mcp.Serve(stdin, stdout, stderr, service)
+ case "help", "-h", "--help":
+ printUsage(stdout)
+ return nil
+ default:
+ return fmt.Errorf("unknown command %q", args[0])
+ }
+}
+
+func runSession(args []string, stdout io.Writer, service *ledger.Service) error {
+ if len(args) == 0 {
+ return fmt.Errorf("session command is required")
+ }
+ switch args[0] {
+ case "start":
+ name, err := stringFlag(args[1:], "--name")
+ if err != nil {
+ return err
+ }
+ status, err := service.StartSession(name)
+ if err != nil {
+ return err
+ }
+ fmt.Fprintf(stdout, "started session %s (%s)\n", status.ID, status.Name)
+ return nil
+ case "status":
+ jsonOut, err := parseJSONFlag(args[1:])
+ if err != nil {
+ return err
+ }
+ status, err := service.SessionStatus()
+ if err != nil {
+ return err
+ }
+ if jsonOut {
+ return writeJSON(stdout, status)
+ }
+ if !status.Active {
+ fmt.Fprintln(stdout, "no active session")
+ return nil
+ }
+ fmt.Fprintf(stdout, "%s (%s): %s elapsed\n", status.ID, status.Name, status.ElapsedHuman)
+ return nil
+ case "end":
+ status, err := service.EndSession()
+ if err != nil {
+ return err
+ }
+ fmt.Fprintf(stdout, "ended session %s (%s): %s elapsed\n", status.ID, status.Name, status.ElapsedHuman)
+ return nil
+ default:
+ return fmt.Errorf("unknown session command %q", args[0])
+ }
+}
+
+func runMark(args []string, stdout io.Writer, service *ledger.Service) error {
+ if len(args) == 0 {
+ return fmt.Errorf("mark command is required")
+ }
+ switch args[0] {
+ case "start":
+ if len(args) != 2 {
+ return fmt.Errorf("usage: atl mark start <name>")
+ }
+ mark, err := service.StartMark(args[1])
+ if err != nil {
+ return err
+ }
+ fmt.Fprintf(stdout, "started mark %s at %s\n", mark.Name, mark.StartedAt)
+ return nil
+ case "check":
+ if len(args) < 2 {
+ return fmt.Errorf("usage: atl mark check <name> [--json]")
+ }
+ jsonOut, rest, err := extractJSONFlag(args[2:])
+ if err != nil {
+ return err
+ }
+ if len(rest) != 0 {
+ return fmt.Errorf("unexpected argument %q", rest[0])
+ }
+ mark, err := service.MarkElapsed(args[1])
+ if err != nil {
+ return err
+ }
+ if jsonOut {
+ return writeJSON(stdout, mark)
+ }
+ fmt.Fprintf(stdout, "%s: %s elapsed\n", mark.Name, mark.ElapsedHuman)
+ return nil
+ case "list":
+ marks, err := service.ListMarks()
+ if err != nil {
+ return err
+ }
+ sort.Slice(marks, func(i, j int) bool { return marks[i].Name < marks[j].Name })
+ if len(marks) == 0 {
+ fmt.Fprintln(stdout, "no marks")
+ return nil
+ }
+ for _, mark := range marks {
+ fmt.Fprintf(stdout, "%s\t%s\t%s\n", mark.Name, mark.StartedAt, mark.ElapsedHuman)
+ }
+ return nil
+ case "delete":
+ if len(args) != 2 {
+ return fmt.Errorf("usage: atl mark delete <name>")
+ }
+ if err := service.DeleteMark(args[1]); err != nil {
+ return err
+ }
+ fmt.Fprintf(stdout, "deleted mark %s\n", args[1])
+ return nil
+ default:
+ return fmt.Errorf("unknown mark command %q", args[0])
+ }
+}
+
+func runStale(args []string, stdout io.Writer, service *ledger.Service) error {
+ jsonOut, rest, err := extractJSONFlag(args)
+ if err != nil {
+ return err
+ }
+ timestampValue, _ := optionalStringFlag(rest, "--timestamp")
+ markName, _ := optionalStringFlag(rest, "--mark")
+ ttlValue, err := stringFlag(rest, "--ttl")
+ if err != nil {
+ return err
+ }
+ ttl, err := clock.ParseDuration(ttlValue)
+ if err != nil {
+ return err
+ }
+
+ var response ledger.StaleResponse
+ switch {
+ case timestampValue != "" && markName != "":
+ return fmt.Errorf("use either --timestamp or --mark, not both")
+ case timestampValue != "":
+ reference, err := clock.ParseRFC3339(timestampValue)
+ if err != nil {
+ return fmt.Errorf("invalid --timestamp: %w", err)
+ }
+ response, err = service.StaleFromTimestamp(reference, ttl)
+ case markName != "":
+ response, err = service.StaleFromMark(markName, ttl)
+ default:
+ return fmt.Errorf("either --timestamp or --mark is required")
+ }
+ if err != nil {
+ return err
+ }
+ if jsonOut {
+ return writeJSON(stdout, response)
+ }
+ fmt.Fprintf(stdout, "stale=%t elapsed=%s ttl=%s\n", response.Stale, response.ElapsedHuman, response.TTL)
+ return nil
+}
+
+func runReport(args []string, stdout io.Writer, service *ledger.Service) error {
+ jsonOut, rest, err := extractJSONFlag(args)
+ if err != nil {
+ return err
+ }
+ if len(rest) > 1 || (len(rest) == 1 && rest[0] != "today") {
+ return fmt.Errorf("usage: atl report [today] [--json]")
+ }
+ report, err := service.ReportToday()
+ if err != nil {
+ return err
+ }
+ if jsonOut {
+ return writeJSON(stdout, report)
+ }
+ fmt.Fprintf(stdout, "report %s: %d events, %d open marks, active_session=%t\n", report.Date, report.TotalEventsToday, report.OpenMarks, report.ActiveSession)
+ return nil
+}
+
+func printUsage(w io.Writer) {
+ fmt.Fprintln(w, `Usage:
+ atl now [--json]
+ atl session start --name <name>
+ atl session status [--json]
+ atl session end
+ atl mark start <name>
+ atl mark check <name> [--json]
+ atl mark list
+ atl mark delete <name>
+ atl stale (--timestamp <rfc3339> | --mark <name>) --ttl <duration> [--json]
+ atl report [today] [--json]
+ atl serve-mcp`)
+}
+
+func parseJSONFlag(args []string) (bool, error) {
+ jsonOut, rest, err := extractJSONFlag(args)
+ if err != nil {
+ return false, err
+ }
+ if len(rest) != 0 {
+ return false, fmt.Errorf("unexpected argument %q", rest[0])
+ }
+ return jsonOut, nil
+}
+
+func extractJSONFlag(args []string) (bool, []string, error) {
+ jsonOut := false
+ rest := make([]string, 0, len(args))
+ for _, arg := range args {
+ if arg == "--json" {
+ jsonOut = true
+ continue
+ }
+ rest = append(rest, arg)
+ }
+ return jsonOut, rest, nil
+}
+
+func stringFlag(args []string, name string) (string, error) {
+ value, ok := optionalStringFlag(args, name)
+ if !ok || value == "" {
+ return "", fmt.Errorf("%s is required", name)
+ }
+ return value, nil
+}
+
+func optionalStringFlag(args []string, name string) (string, bool) {
+ for i, arg := range args {
+ if arg == name && i+1 < len(args) {
+ return args[i+1], true
+ }
+ if strings.HasPrefix(arg, name+"=") {
+ return strings.TrimPrefix(arg, name+"="), true
+ }
+ }
+ return "", false
+}
+
+func writeJSON(w io.Writer, value any) error {
+ encoder := json.NewEncoder(w)
+ encoder.SetIndent("", " ")
+ return encoder.Encode(value)
+}
internal/clock/clock.go +127 -0
@@ -0,0 +1,127 @@
+package clock
+
+import (
+ "fmt"
+ "math"
+ "regexp"
+ "strconv"
+ "strings"
+ "time"
+)
+
+const (
+ ConfidenceHostClock = "host_clock"
+ ConfidenceMonotonic = "monotonic"
+ ConfidenceWallFallback = "wall_clock_fallback"
+ ConfidenceUnknown = "unknown"
+)
+
+var durationPart = regexp.MustCompile(`(?i)(\d+(?:\.\d+)?)(ns|us|ยตs|ms|s|m|h|d)`)
+
+func Now() time.Time {
+ return time.Now().UTC()
+}
+
+func FormatRFC3339(t time.Time) string {
+ return t.UTC().Format(time.RFC3339Nano)
+}
+
+func ParseRFC3339(value string) (time.Time, error) {
+ t, err := time.Parse(time.RFC3339Nano, value)
+ if err != nil {
+ return time.Time{}, err
+ }
+ return t.UTC(), nil
+}
+
+func ParseDuration(value string) (time.Duration, error) {
+ value = strings.TrimSpace(value)
+ if value == "" {
+ return 0, fmt.Errorf("duration is required")
+ }
+
+ matches := durationPart.FindAllStringSubmatchIndex(value, -1)
+ if len(matches) == 0 {
+ return 0, fmt.Errorf("invalid duration %q", value)
+ }
+
+ var total time.Duration
+ pos := 0
+ for _, match := range matches {
+ if match[0] != pos {
+ return 0, fmt.Errorf("invalid duration %q", value)
+ }
+
+ numberText := value[match[2]:match[3]]
+ unit := strings.ToLower(value[match[4]:match[5]])
+ number, err := strconv.ParseFloat(numberText, 64)
+ if err != nil {
+ return 0, fmt.Errorf("invalid duration %q: %w", value, err)
+ }
+
+ var unitDuration time.Duration
+ switch unit {
+ case "ns":
+ unitDuration = time.Nanosecond
+ case "us", "ยตs":
+ unitDuration = time.Microsecond
+ case "ms":
+ unitDuration = time.Millisecond
+ case "s":
+ unitDuration = time.Second
+ case "m":
+ unitDuration = time.Minute
+ case "h":
+ unitDuration = time.Hour
+ case "d":
+ unitDuration = 24 * time.Hour
+ default:
+ return 0, fmt.Errorf("invalid duration unit %q", unit)
+ }
+
+ part := number * float64(unitDuration)
+ if part > float64(math.MaxInt64) {
+ return 0, fmt.Errorf("duration %q is too large", value)
+ }
+ total += time.Duration(part)
+ pos = match[1]
+ }
+
+ if pos != len(value) || total <= 0 {
+ return 0, fmt.Errorf("invalid duration %q", value)
+ }
+ return total, nil
+}
+
+func FormatDuration(d time.Duration) string {
+ if d < 0 {
+ return "-" + FormatDuration(-d)
+ }
+ if d < time.Second {
+ return d.String()
+ }
+
+ d = d.Round(time.Second)
+ days := d / (24 * time.Hour)
+ d -= days * 24 * time.Hour
+ hours := d / time.Hour
+ d -= hours * time.Hour
+ minutes := d / time.Minute
+ d -= minutes * time.Minute
+ seconds := d / time.Second
+
+ var parts []string
+ if days > 0 {
+ parts = append(parts, fmt.Sprintf("%dd", days))
+ }
+ if hours > 0 {
+ parts = append(parts, fmt.Sprintf("%dh", hours))
+ }
+ if minutes > 0 {
+ parts = append(parts, fmt.Sprintf("%dm", minutes))
+ }
+ if seconds > 0 || len(parts) == 0 {
+ parts = append(parts, fmt.Sprintf("%ds", seconds))
+ }
+ return strings.Join(parts, " ")
+}
internal/clock/clock_test.go +44 -0
@@ -0,0 +1,44 @@
+package clock
+
+import (
+ "testing"
+ "time"
+)
+
+func TestParseDuration(t *testing.T) {
+ tests := map[string]time.Duration{
+ "15s": 15 * time.Second,
+ "30m": 30 * time.Minute,
+ "2h": 2 * time.Hour,
+ "1d": 24 * time.Hour,
+ }
+ for input, want := range tests {
+ got, err := ParseDuration(input)
+ if err != nil {
+ t.Fatalf("ParseDuration(%q) returned error: %v", input, err)
+ }
+ if got != want {
+ t.Fatalf("ParseDuration(%q) = %s, want %s", input, got, want)
+ }
+ }
+}
+
+func TestFormatDuration(t *testing.T) {
+ got := FormatDuration(26*time.Hour + 3*time.Minute + 4*time.Second)
+ want := "1d 2h 3m 4s"
+ if got != want {
+ t.Fatalf("FormatDuration returned %q, want %q", got, want)
+ }
+}
+
+func TestRFC3339RoundTrip(t *testing.T) {
+ now := time.Date(2026, 6, 19, 6, 30, 0, 123, time.UTC)
+ text := FormatRFC3339(now)
+ got, err := ParseRFC3339(text)
+ if err != nil {
+ t.Fatalf("ParseRFC3339 returned error: %v", err)
+ }
+ if !got.Equal(now) {
+ t.Fatalf("round trip = %s, want %s", got, now)
+ }
+}
internal/ledger/ledger.go +372 -0
@@ -0,0 +1,372 @@
+package ledger
+
+import (
+ "fmt"
+ "time"
+
+ "github.com/zionboggan/agent-time-ledger/internal/clock"
+ "github.com/zionboggan/agent-time-ledger/internal/state"
+)
+
+type Service struct {
+ Store *state.Store
+ Now func() time.Time
+}
+
+type NowResponse struct {
+ Timestamp string `json:"timestamp"`
+ Unix int64 `json:"unix"`
+ Timezone string `json:"timezone"`
+ Confidence string `json:"confidence"`
+}
+
+type SessionStatusResponse struct {
+ Active bool `json:"active"`
+ ID string `json:"id,omitempty"`
+ Name string `json:"name,omitempty"`
+ StartedAt string `json:"started_at,omitempty"`
+ EndedAt string `json:"ended_at,omitempty"`
+ ElapsedSeconds float64 `json:"elapsed_seconds,omitempty"`
+ ElapsedHuman string `json:"elapsed_human,omitempty"`
+ Confidence string `json:"confidence"`
+}
+
+type MarkElapsedResponse struct {
+ Name string `json:"name"`
+ StartedAt string `json:"started_at"`
+ Now string `json:"now"`
+ ElapsedSeconds float64 `json:"elapsed_seconds"`
+ ElapsedHuman string `json:"elapsed_human"`
+ Confidence string `json:"confidence"`
+}
+
+type MarkSummary struct {
+ Name string `json:"name"`
+ StartedAt string `json:"started_at"`
+ ElapsedSeconds float64 `json:"elapsed_seconds"`
+ ElapsedHuman string `json:"elapsed_human"`
+ Confidence string `json:"confidence"`
+}
+
+type StaleResponse struct {
+ CheckedAt string `json:"checked_at"`
+ ReferenceTime string `json:"reference_time"`
+ TTL string `json:"ttl"`
+ ElapsedSeconds float64 `json:"elapsed_seconds"`
+ ElapsedHuman string `json:"elapsed_human"`
+ Stale bool `json:"stale"`
+ Confidence string `json:"confidence"`
+}
+
+type ReportResponse struct {
+ Date string `json:"date"`
+ GeneratedAt string `json:"generated_at"`
+ ActiveSession bool `json:"active_session"`
+ ActiveSessionID string `json:"active_session_id,omitempty"`
+ SessionsStarted int `json:"sessions_started"`
+ SessionsEnded int `json:"sessions_ended"`
+ OpenMarks int `json:"open_marks"`
+ EventsByType map[string]int `json:"events_by_type"`
+ TotalEventsToday int `json:"total_events_today"`
+ Confidence string `json:"confidence"`
+}
+
+func NewService(store *state.Store) *Service {
+ return &Service{Store: store, Now: clock.Now}
+}
+
+func (s *Service) NowResponse() NowResponse {
+ now := s.now()
+ return NowResponse{
+ Timestamp: clock.FormatRFC3339(now),
+ Unix: now.Unix(),
+ Timezone: "UTC",
+ Confidence: clock.ConfidenceHostClock,
+ }
+}
+
+func (s *Service) StartSession(name string) (SessionStatusResponse, error) {
+ if name == "" {
+ return SessionStatusResponse{}, fmt.Errorf("session name is required")
+ }
+ st, err := s.Store.Load()
+ if err != nil {
+ return SessionStatusResponse{}, err
+ }
+ if st.ActiveSessionID != "" {
+ return SessionStatusResponse{}, fmt.Errorf("session already active: %s", st.ActiveSessionID)
+ }
+ now := s.now()
+ id := "sess_" + now.Format("20060102T150405.000000000Z")
+ session := state.Session{ID: id, Name: name, StartedAt: now}
+ st.ActiveSessionID = id
+ st.Sessions[id] = session
+ if err := s.Store.Save(st); err != nil {
+ return SessionStatusResponse{}, err
+ }
+ if err := s.Store.AppendEvent(state.Event{
+ Timestamp: now,
+ Type: "session_start",
+ Fields: map[string]any{"session_id": id, "name": name},
+ }); err != nil {
+ return SessionStatusResponse{}, err
+ }
+ return s.sessionResponse(session, now), nil
+}
+
+func (s *Service) SessionStatus() (SessionStatusResponse, error) {
+ st, err := s.Store.Load()
+ if err != nil {
+ return SessionStatusResponse{}, err
+ }
+ if st.ActiveSessionID == "" {
+ return SessionStatusResponse{Active: false, Confidence: clock.ConfidenceWallFallback}, nil
+ }
+ session, ok := st.Sessions[st.ActiveSessionID]
+ if !ok {
+ return SessionStatusResponse{}, fmt.Errorf("active session %q not found in state", st.ActiveSessionID)
+ }
+ return s.sessionResponse(session, s.now()), nil
+}
+
+func (s *Service) EndSession() (SessionStatusResponse, error) {
+ st, err := s.Store.Load()
+ if err != nil {
+ return SessionStatusResponse{}, err
+ }
+ if st.ActiveSessionID == "" {
+ return SessionStatusResponse{}, fmt.Errorf("no active session")
+ }
+ session, ok := st.Sessions[st.ActiveSessionID]
+ if !ok {
+ return SessionStatusResponse{}, fmt.Errorf("active session %q not found in state", st.ActiveSessionID)
+ }
+ now := s.now()
+ session.EndedAt = &now
+ st.Sessions[session.ID] = session
+ st.ActiveSessionID = ""
+ if err := s.Store.Save(st); err != nil {
+ return SessionStatusResponse{}, err
+ }
+ if err := s.Store.AppendEvent(state.Event{
+ Timestamp: now,
+ Type: "session_end",
+ Fields: map[string]any{"session_id": session.ID, "name": session.Name},
+ }); err != nil {
+ return SessionStatusResponse{}, err
+ }
+ return s.sessionResponse(session, now), nil
+}
+
+func (s *Service) StartMark(name string) (MarkElapsedResponse, error) {
+ if name == "" {
+ return MarkElapsedResponse{}, fmt.Errorf("mark name is required")
+ }
+ st, err := s.Store.Load()
+ if err != nil {
+ return MarkElapsedResponse{}, err
+ }
+ now := s.now()
+ mark := state.Mark{Name: name, StartedAt: now}
+ st.Marks[name] = mark
+ if err := s.Store.Save(st); err != nil {
+ return MarkElapsedResponse{}, err
+ }
+ if err := s.Store.AppendEvent(state.Event{
+ Timestamp: now,
+ Type: "mark_start",
+ Fields: map[string]any{"name": name},
+ }); err != nil {
+ return MarkElapsedResponse{}, err
+ }
+ return s.markResponse(mark, now), nil
+}
+
+func (s *Service) MarkElapsed(name string) (MarkElapsedResponse, error) {
+ st, err := s.Store.Load()
+ if err != nil {
+ return MarkElapsedResponse{}, err
+ }
+ mark, ok := st.Marks[name]
+ if !ok {
+ return MarkElapsedResponse{}, fmt.Errorf("mark %q not found", name)
+ }
+ return s.markResponse(mark, s.now()), nil
+}
+
+func (s *Service) ListMarks() ([]MarkSummary, error) {
+ st, err := s.Store.Load()
+ if err != nil {
+ return nil, err
+ }
+ now := s.now()
+ marks := make([]MarkSummary, 0, len(st.Marks))
+ for _, mark := range st.Marks {
+ elapsed := now.Sub(mark.StartedAt)
+ marks = append(marks, MarkSummary{
+ Name: mark.Name,
+ StartedAt: clock.FormatRFC3339(mark.StartedAt),
+ ElapsedSeconds: elapsed.Seconds(),
+ ElapsedHuman: clock.FormatDuration(elapsed),
+ Confidence: clock.ConfidenceWallFallback,
+ })
+ }
+ return marks, nil
+}
+
+func (s *Service) DeleteMark(name string) error {
+ st, err := s.Store.Load()
+ if err != nil {
+ return err
+ }
+ if _, ok := st.Marks[name]; !ok {
+ return fmt.Errorf("mark %q not found", name)
+ }
+ delete(st.Marks, name)
+ now := s.now()
+ if err := s.Store.Save(st); err != nil {
+ return err
+ }
+ return s.Store.AppendEvent(state.Event{
+ Timestamp: now,
+ Type: "mark_delete",
+ Fields: map[string]any{"name": name},
+ })
+}
+
+func (s *Service) StaleFromTimestamp(reference time.Time, ttl time.Duration) (StaleResponse, error) {
+ return s.stale(reference.UTC(), ttl, map[string]any{"source": "timestamp"})
+}
+
+func (s *Service) StaleFromMark(name string, ttl time.Duration) (StaleResponse, error) {
+ st, err := s.Store.Load()
+ if err != nil {
+ return StaleResponse{}, err
+ }
+ mark, ok := st.Marks[name]
+ if !ok {
+ return StaleResponse{}, fmt.Errorf("mark %q not found", name)
+ }
+ return s.stale(mark.StartedAt, ttl, map[string]any{"source": "mark", "name": name})
+}
+
+func (s *Service) LedgerEvent(note string) error {
+ now := s.now()
+ return s.Store.AppendEvent(state.Event{
+ Timestamp: now,
+ Type: "manual_note",
+ Fields: map[string]any{"note": note},
+ })
+}
+
+func (s *Service) ReportToday() (ReportResponse, error) {
+ st, err := s.Store.Load()
+ if err != nil {
+ return ReportResponse{}, err
+ }
+ events, err := s.Store.ReadEvents()
+ if err != nil {
+ return ReportResponse{}, err
+ }
+
+ now := s.now()
+ start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
+ end := start.Add(24 * time.Hour)
+ report := ReportResponse{
+ Date: start.Format("2006-01-02"),
+ GeneratedAt: clock.FormatRFC3339(now),
+ ActiveSession: st.ActiveSessionID != "",
+ ActiveSessionID: st.ActiveSessionID,
+ OpenMarks: len(st.Marks),
+ EventsByType: map[string]int{},
+ Confidence: clock.ConfidenceWallFallback,
+ }
+ for _, event := range events {
+ if event.Timestamp.Before(start) || !event.Timestamp.Before(end) {
+ continue
+ }
+ report.EventsByType[event.Type]++
+ report.TotalEventsToday++
+ switch event.Type {
+ case "session_start":
+ report.SessionsStarted++
+ case "session_end":
+ report.SessionsEnded++
+ }
+ }
+ return report, nil
+}
+
+func (s *Service) stale(reference time.Time, ttl time.Duration, fields map[string]any) (StaleResponse, error) {
+ if ttl <= 0 {
+ return StaleResponse{}, fmt.Errorf("ttl must be greater than zero")
+ }
+ now := s.now()
+ elapsed := now.Sub(reference)
+ response := StaleResponse{
+ CheckedAt: clock.FormatRFC3339(now),
+ ReferenceTime: clock.FormatRFC3339(reference),
+ TTL: ttl.String(),
+ ElapsedSeconds: elapsed.Seconds(),
+ ElapsedHuman: clock.FormatDuration(elapsed),
+ Stale: elapsed > ttl,
+ Confidence: clock.ConfidenceWallFallback,
+ }
+ eventFields := map[string]any{
+ "reference_time": response.ReferenceTime,
+ "ttl": response.TTL,
+ "elapsed_seconds": response.ElapsedSeconds,
+ "stale": response.Stale,
+ }
+ for key, value := range fields {
+ eventFields[key] = value
+ }
+ if err := s.Store.AppendEvent(state.Event{
+ Timestamp: now,
+ Type: "stale_check",
+ Fields: eventFields,
+ }); err != nil {
+ return StaleResponse{}, err
+ }
+ return response, nil
+}
+
+func (s *Service) sessionResponse(session state.Session, now time.Time) SessionStatusResponse {
+ end := now
+ endedAt := ""
+ active := session.EndedAt == nil
+ if session.EndedAt != nil {
+ end = *session.EndedAt
+ endedAt = clock.FormatRFC3339(*session.EndedAt)
+ }
+ elapsed := end.Sub(session.StartedAt)
+ return SessionStatusResponse{
+ Active: active,
+ ID: session.ID,
+ Name: session.Name,
+ StartedAt: clock.FormatRFC3339(session.StartedAt),
+ EndedAt: endedAt,
+ ElapsedSeconds: elapsed.Seconds(),
+ ElapsedHuman: clock.FormatDuration(elapsed),
+ Confidence: clock.ConfidenceWallFallback,
+ }
+}
+
+func (s *Service) markResponse(mark state.Mark, now time.Time) MarkElapsedResponse {
+ elapsed := now.Sub(mark.StartedAt)
+ return MarkElapsedResponse{
+ Name: mark.Name,
+ StartedAt: clock.FormatRFC3339(mark.StartedAt),
+ Now: clock.FormatRFC3339(now),
+ ElapsedSeconds: elapsed.Seconds(),
+ ElapsedHuman: clock.FormatDuration(elapsed),
+ Confidence: clock.ConfidenceWallFallback,
+ }
+}
+
+func (s *Service) now() time.Time {
+ if s.Now != nil {
+ return s.Now().UTC()
+ }
+ return clock.Now()
+}
internal/ledger/ledger_test.go +150 -0
@@ -0,0 +1,150 @@
+package ledger
+
+import (
+ "bufio"
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/zionboggan/agent-time-ledger/internal/state"
+)
+
+func TestNowResponseIsRFC3339(t *testing.T) {
+ service := testService(t, time.Date(2026, 6, 19, 6, 30, 0, 0, time.UTC))
+ response := service.NowResponse()
+ if _, err := time.Parse(time.RFC3339Nano, response.Timestamp); err != nil {
+ t.Fatalf("timestamp %q is not RFC3339: %v", response.Timestamp, err)
+ }
+ if response.Confidence != "host_clock" {
+ t.Fatalf("confidence = %q, want host_clock", response.Confidence)
+ }
+}
+
+func TestStaleCheckFreshBeforeTTL(t *testing.T) {
+ now := time.Date(2026, 6, 19, 6, 30, 0, 0, time.UTC)
+ service := testService(t, now)
+ response, err := service.StaleFromTimestamp(now.Add(-14*time.Minute), 15*time.Minute)
+ if err != nil {
+ t.Fatalf("StaleFromTimestamp returned error: %v", err)
+ }
+ if response.Stale {
+ t.Fatal("expected fresh before TTL")
+ }
+}
+
+func TestStaleCheckStaleAfterTTL(t *testing.T) {
+ now := time.Date(2026, 6, 19, 6, 30, 0, 0, time.UTC)
+ service := testService(t, now)
+ response, err := service.StaleFromTimestamp(now.Add(-16*time.Minute), 15*time.Minute)
+ if err != nil {
+ t.Fatalf("StaleFromTimestamp returned error: %v", err)
+ }
+ if !response.Stale {
+ t.Fatal("expected stale after TTL")
+ }
+}
+
+func TestMissingMarkError(t *testing.T) {
+ service := testService(t, time.Date(2026, 6, 19, 6, 30, 0, 0, time.UTC))
+ if _, err := service.MarkElapsed("missing"); err == nil {
+ t.Fatal("expected missing mark error")
+ }
+}
+
+func TestSessionStatusNoActiveSession(t *testing.T) {
+ service := testService(t, time.Date(2026, 6, 19, 6, 30, 0, 0, time.UTC))
+ status, err := service.SessionStatus()
+ if err != nil {
+ t.Fatalf("SessionStatus returned error: %v", err)
+ }
+ if status.Active {
+ t.Fatal("expected no active session")
+ }
+}
+
+func TestStatePersistence(t *testing.T) {
+ dir := t.TempDir()
+ start := time.Date(2026, 6, 19, 6, 30, 0, 0, time.UTC)
+ first := NewService(state.NewStore(dir))
+ first.Now = func() time.Time { return start }
+ if _, err := first.StartSession("build"); err != nil {
+ t.Fatalf("StartSession returned error: %v", err)
+ }
+ if _, err := first.StartMark("compile"); err != nil {
+ t.Fatalf("StartMark returned error: %v", err)
+ }
+
+ second := NewService(state.NewStore(dir))
+ second.Now = func() time.Time { return start.Add(2 * time.Hour) }
+ status, err := second.SessionStatus()
+ if err != nil {
+ t.Fatalf("SessionStatus returned error: %v", err)
+ }
+ if !status.Active || status.Name != "build" {
+ t.Fatalf("unexpected persisted status: %+v", status)
+ }
+ if status.ElapsedSeconds != 7200 {
+ t.Fatalf("elapsed = %f, want 7200", status.ElapsedSeconds)
+ }
+ mark, err := second.MarkElapsed("compile")
+ if err != nil {
+ t.Fatalf("MarkElapsed returned error: %v", err)
+ }
+ if mark.ElapsedSeconds != 7200 {
+ t.Fatalf("mark elapsed = %f, want 7200", mark.ElapsedSeconds)
+ }
+}
+
+func TestJSONLEventValidity(t *testing.T) {
+ dir := t.TempDir()
+ service := NewService(state.NewStore(dir))
+ service.Now = func() time.Time {
+ return time.Date(2026, 6, 19, 6, 30, 0, 0, time.UTC)
+ }
+ if _, err := service.StartSession("build"); err != nil {
+ t.Fatalf("StartSession returned error: %v", err)
+ }
+ if _, err := service.StartMark("compile"); err != nil {
+ t.Fatalf("StartMark returned error: %v", err)
+ }
+ if err := service.DeleteMark("compile"); err != nil {
+ t.Fatalf("DeleteMark returned error: %v", err)
+ }
+ if _, err := service.StaleFromTimestamp(service.Now().Add(-2*time.Hour), time.Hour); err != nil {
+ t.Fatalf("StaleFromTimestamp returned error: %v", err)
+ }
+
+ file, err := os.Open(filepath.Join(dir, state.EventsFile))
+ if err != nil {
+ t.Fatalf("open events file: %v", err)
+ }
+ defer file.Close()
+
+ count := 0
+ scanner := bufio.NewScanner(file)
+ for scanner.Scan() {
+ count++
+ var event state.Event
+ if err := json.Unmarshal(scanner.Bytes(), &event); err != nil {
+ t.Fatalf("event line %d is invalid JSON: %v", count, err)
+ }
+ if event.Type == "" || event.Timestamp.IsZero() {
+ t.Fatalf("event line %d missing type or timestamp: %+v", count, event)
+ }
+ }
+ if err := scanner.Err(); err != nil {
+ t.Fatalf("scan events: %v", err)
+ }
+ if count != 4 {
+ t.Fatalf("event count = %d, want 4", count)
+ }
+}
+
+func testService(t *testing.T, now time.Time) *Service {
+ t.Helper()
+ service := NewService(state.NewStore(t.TempDir()))
+ service.Now = func() time.Time { return now }
+ return service
+}
internal/mcp/mcp.go +265 -0
@@ -0,0 +1,265 @@
+package mcp
+
+import (
+ "bufio"
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "strconv"
+ "strings"
+
+ "github.com/zionboggan/agent-time-ledger/internal/clock"
+ "github.com/zionboggan/agent-time-ledger/internal/ledger"
+)
+
+type request struct {
+ JSONRPC string `json:"jsonrpc"`
+ ID any `json:"id,omitempty"`
+ Method string `json:"method"`
+ Params json.RawMessage `json:"params,omitempty"`
+}
+
+type response struct {
+ JSONRPC string `json:"jsonrpc"`
+ ID any `json:"id,omitempty"`
+ Result any `json:"result,omitempty"`
+ Error *rpcError `json:"error,omitempty"`
+}
+
+type rpcError struct {
+ Code int `json:"code"`
+ Message string `json:"message"`
+}
+
+type toolCallParams struct {
+ Name string `json:"name"`
+ Arguments json.RawMessage `json:"arguments,omitempty"`
+}
+
+type callResult struct {
+ Content []content `json:"content"`
+ IsError bool `json:"isError,omitempty"`
+}
+
+type content struct {
+ Type string `json:"type"`
+ Text string `json:"text"`
+}
+
+func Serve(stdin io.Reader, stdout, stderr io.Writer, service *ledger.Service) error {
+ reader := bufio.NewReader(stdin)
+ for {
+ body, err := readMessage(reader)
+ if err == io.EOF {
+ return nil
+ }
+ if err != nil {
+ fmt.Fprintln(stderr, err)
+ return err
+ }
+ if len(bytes.TrimSpace(body)) == 0 {
+ continue
+ }
+
+ var req request
+ if err := json.Unmarshal(body, &req); err != nil {
+ _ = writeMessage(stdout, response{
+ JSONRPC: "2.0",
+ Error: &rpcError{Code: -32700, Message: "parse error"},
+ })
+ continue
+ }
+ if strings.HasPrefix(req.Method, "notifications/") {
+ continue
+ }
+ resp := handle(req, service)
+ if err := writeMessage(stdout, resp); err != nil {
+ return err
+ }
+ }
+}
+
+func handle(req request, service *ledger.Service) response {
+ resp := response{JSONRPC: "2.0", ID: req.ID}
+ switch req.Method {
+ case "initialize":
+ resp.Result = map[string]any{
+ "protocolVersion": "2024-11-05",
+ "capabilities": map[string]any{
+ "tools": map[string]any{},
+ },
+ "serverInfo": map[string]any{
+ "name": "agent-time-ledger",
+ "version": "0.1.0",
+ },
+ }
+ case "tools/list":
+ resp.Result = map[string]any{"tools": tools()}
+ case "tools/call":
+ result, err := handleToolCall(req.Params, service)
+ if err != nil {
+ resp.Result = callResult{
+ Content: []content{{Type: "text", Text: err.Error()}},
+ IsError: true,
+ }
+ } else {
+ resp.Result = result
+ }
+ default:
+ resp.Error = &rpcError{Code: -32601, Message: "method not found"}
+ }
+ return resp
+}
+
+func handleToolCall(raw json.RawMessage, service *ledger.Service) (callResult, error) {
+ var params toolCallParams
+ if err := json.Unmarshal(raw, &params); err != nil {
+ return callResult{}, fmt.Errorf("invalid tool call params: %w", err)
+ }
+ args := map[string]any{}
+ if len(params.Arguments) > 0 {
+ if err := json.Unmarshal(params.Arguments, &args); err != nil {
+ return callResult{}, fmt.Errorf("invalid tool arguments: %w", err)
+ }
+ }
+
+ var result any
+ var err error
+ switch params.Name {
+ case "time_now":
+ result = service.NowResponse()
+ case "session_status":
+ result, err = service.SessionStatus()
+ case "mark_start":
+ result, err = service.StartMark(requiredString(args, "name"))
+ case "mark_elapsed":
+ result, err = service.MarkElapsed(requiredString(args, "name"))
+ case "stale_check":
+ result, err = staleCheck(args, service)
+ case "ledger_event":
+ note, _ := args["note"].(string)
+ err = service.LedgerEvent(note)
+ result = map[string]any{"ok": err == nil, "confidence": clock.ConfidenceWallFallback}
+ case "ledger_report":
+ result, err = service.ReportToday()
+ default:
+ return callResult{}, fmt.Errorf("unknown tool %q", params.Name)
+ }
+ if err != nil {
+ return callResult{}, err
+ }
+ text, err := json.MarshalIndent(result, "", " ")
+ if err != nil {
+ return callResult{}, err
+ }
+ return callResult{Content: []content{{Type: "text", Text: string(text)}}}, nil
+}
+
+func staleCheck(args map[string]any, service *ledger.Service) (ledger.StaleResponse, error) {
+ ttlText := requiredString(args, "ttl")
+ ttl, err := clock.ParseDuration(ttlText)
+ if err != nil {
+ return ledger.StaleResponse{}, err
+ }
+ if markName, ok := args["mark"].(string); ok && markName != "" {
+ return service.StaleFromMark(markName, ttl)
+ }
+ timestampText := requiredString(args, "timestamp")
+ timestamp, err := clock.ParseRFC3339(timestampText)
+ if err != nil {
+ return ledger.StaleResponse{}, err
+ }
+ return service.StaleFromTimestamp(timestamp, ttl)
+}
+
+func requiredString(args map[string]any, name string) string {
+ value, _ := args[name].(string)
+ return value
+}
+
+func tools() []map[string]any {
+ return []map[string]any{
+ tool("time_now", "Return the host clock time.", map[string]any{}),
+ tool("session_status", "Return active session status and elapsed time.", map[string]any{}),
+ tool("mark_start", "Start or reset a named mark.", map[string]any{
+ "name": stringSchema("Mark name"),
+ }, "name"),
+ tool("mark_elapsed", "Return elapsed time for a named mark.", map[string]any{
+ "name": stringSchema("Mark name"),
+ }, "name"),
+ tool("stale_check", "Check whether a timestamp or mark is stale for a TTL.", map[string]any{
+ "timestamp": stringSchema("RFC3339 timestamp"),
+ "mark": stringSchema("Existing mark name"),
+ "ttl": stringSchema("Duration such as 15s, 30m, 2h, or 1d"),
+ }, "ttl"),
+ tool("ledger_event", "Append a local manual_note ledger event.", map[string]any{
+ "note": stringSchema("Short local note"),
+ }),
+ tool("ledger_report", "Return today's local ledger report.", map[string]any{}),
+ }
+}
+
+func tool(name, description string, properties map[string]any, required ...string) map[string]any {
+ if required == nil {
+ required = []string{}
+ }
+ return map[string]any{
+ "name": name,
+ "description": description,
+ "inputSchema": map[string]any{
+ "type": "object",
+ "properties": properties,
+ "required": required,
+ "additionalProperties": false,
+ },
+ }
+}
+
+func stringSchema(description string) map[string]any {
+ return map[string]any{"type": "string", "description": description}
+}
+
+func readMessage(reader *bufio.Reader) ([]byte, error) {
+ line, err := reader.ReadString('\n')
+ if err != nil {
+ if err == io.EOF && strings.TrimSpace(line) != "" {
+ return []byte(line), nil
+ }
+ return nil, err
+ }
+ line = strings.TrimRight(line, "\r\n")
+ if strings.TrimSpace(line) == "" {
+ return []byte{}, nil
+ }
+ if !strings.HasPrefix(strings.ToLower(line), "content-length:") {
+ return []byte(line), nil
+ }
+
+ lengthText := strings.TrimSpace(strings.TrimPrefix(strings.ToLower(line), "content-length:"))
+ length, err := strconv.Atoi(lengthText)
+ if err != nil {
+ return nil, fmt.Errorf("invalid Content-Length: %w", err)
+ }
+ for {
+ header, err := reader.ReadString('\n')
+ if err != nil {
+ return nil, err
+ }
+ if strings.TrimSpace(header) == "" {
+ break
+ }
+ }
+ body := make([]byte, length)
+ _, err = io.ReadFull(reader, body)
+ return body, err
+}
+
+func writeMessage(w io.Writer, value any) error {
+ data, err := json.Marshal(value)
+ if err != nil {
+ return err
+ }
+ _, err = fmt.Fprintf(w, "Content-Length: %d\r\n\r\n%s", len(data), data)
+ return err
+}
internal/state/state.go +220 -0
@@ -0,0 +1,220 @@
+package state
+
+import (
+ "bufio"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "time"
+)
+
+const (
+ StateFile = "state.json"
+ EventsFile = "events.jsonl"
+ ConfigFile = "config.toml"
+)
+
+type State struct {
+ ActiveSessionID string `json:"active_session_id,omitempty"`
+ Sessions map[string]Session `json:"sessions"`
+ Marks map[string]Mark `json:"marks"`
+}
+
+type Session struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ StartedAt time.Time `json:"started_at"`
+ EndedAt *time.Time `json:"ended_at,omitempty"`
+}
+
+type Mark struct {
+ Name string `json:"name"`
+ StartedAt time.Time `json:"started_at"`
+}
+
+type Event struct {
+ Timestamp time.Time `json:"timestamp"`
+ Type string `json:"type"`
+ Fields map[string]any `json:"fields,omitempty"`
+}
+
+type Store struct {
+ Dir string
+}
+
+func DefaultDir() (string, error) {
+ if dir := os.Getenv("ATL_HOME"); dir != "" {
+ return dir, nil
+ }
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(home, ".agent-time-ledger"), nil
+}
+
+func NewStore(dir string) *Store {
+ return &Store{Dir: dir}
+}
+
+func DefaultStore() (*Store, error) {
+ dir, err := DefaultDir()
+ if err != nil {
+ return nil, err
+ }
+ return NewStore(dir), nil
+}
+
+func NewState() State {
+ return State{
+ Sessions: map[string]Session{},
+ Marks: map[string]Mark{},
+ }
+}
+
+func (s *Store) Ensure() error {
+ if s.Dir == "" {
+ return errors.New("store directory is empty")
+ }
+ if err := os.MkdirAll(s.Dir, 0o700); err != nil {
+ return err
+ }
+ if _, err := os.Stat(s.statePath()); errors.Is(err, os.ErrNotExist) {
+ if err := s.Save(NewState()); err != nil {
+ return err
+ }
+ } else if err != nil {
+ return err
+ }
+ if _, err := os.Stat(s.eventsPath()); errors.Is(err, os.ErrNotExist) {
+ file, err := os.OpenFile(s.eventsPath(), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
+ if err != nil {
+ return err
+ }
+ if err := file.Close(); err != nil {
+ return err
+ }
+ } else if err != nil {
+ return err
+ }
+ if _, err := os.Stat(s.configPath()); errors.Is(err, os.ErrNotExist) {
+ if err := os.WriteFile(s.configPath(), []byte("# agent-time-ledger local config\n"), 0o600); err != nil {
+ return err
+ }
+ } else if err != nil {
+ return err
+ }
+ return nil
+}
+
+func (s *Store) Load() (State, error) {
+ if err := s.Ensure(); err != nil {
+ return State{}, err
+ }
+ data, err := os.ReadFile(s.statePath())
+ if err != nil {
+ return State{}, err
+ }
+ if len(data) == 0 {
+ return NewState(), nil
+ }
+
+ var st State
+ if err := json.Unmarshal(data, &st); err != nil {
+ return State{}, err
+ }
+ if st.Sessions == nil {
+ st.Sessions = map[string]Session{}
+ }
+ if st.Marks == nil {
+ st.Marks = map[string]Mark{}
+ }
+ return st, nil
+}
+
+func (s *Store) Save(st State) error {
+ if st.Sessions == nil {
+ st.Sessions = map[string]Session{}
+ }
+ if st.Marks == nil {
+ st.Marks = map[string]Mark{}
+ }
+ if err := os.MkdirAll(s.Dir, 0o700); err != nil {
+ return err
+ }
+ data, err := json.MarshalIndent(st, "", " ")
+ if err != nil {
+ return err
+ }
+ data = append(data, '\n')
+ tmp := s.statePath() + ".tmp"
+ if err := os.WriteFile(tmp, data, 0o600); err != nil {
+ return err
+ }
+ return os.Rename(tmp, s.statePath())
+}
+
+func (s *Store) AppendEvent(event Event) error {
+ if err := s.Ensure(); err != nil {
+ return err
+ }
+ file, err := os.OpenFile(s.eventsPath(), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
+ if err != nil {
+ return err
+ }
+ defer file.Close()
+
+ line, err := json.Marshal(event)
+ if err != nil {
+ return err
+ }
+ if _, err := file.Write(append(line, '\n')); err != nil {
+ return err
+ }
+ return nil
+}
+
+func (s *Store) ReadEvents() ([]Event, error) {
+ if err := s.Ensure(); err != nil {
+ return nil, err
+ }
+ file, err := os.Open(s.eventsPath())
+ if err != nil {
+ return nil, err
+ }
+ defer file.Close()
+
+ var events []Event
+ scanner := bufio.NewScanner(file)
+ lineNumber := 0
+ for scanner.Scan() {
+ lineNumber++
+ line := scanner.Bytes()
+ if len(line) == 0 {
+ continue
+ }
+ var event Event
+ if err := json.Unmarshal(line, &event); err != nil {
+ return nil, fmt.Errorf("%s:%d: %w", EventsFile, lineNumber, err)
+ }
+ events = append(events, event)
+ }
+ if err := scanner.Err(); err != nil {
+ return nil, err
+ }
+ return events, nil
+}
+
+func (s *Store) statePath() string {
+ return filepath.Join(s.Dir, StateFile)
+}
+
+func (s *Store) eventsPath() string {
+ return filepath.Join(s.Dir, EventsFile)
+}
+
+func (s *Store) configPath() string {
+ return filepath.Join(s.Dir, ConfigFile)
+}