| @@ -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> |
| @@ -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> |
| @@ -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 ./... |
| @@ -0,0 +1,3 @@ | ||
| + | atl | |
| + | dist/ | |
| + | coverage.out |
| @@ -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. |
| @@ -1,2 +1,102 @@ | ||
| # agent-time-ledger | ||
| - | Local-first elapsed-time ledger for software agents. | |
| + | ||
| + | [](https://github.com/ZionBoggan/agent-time-ledger/actions/workflows/test.yml) | |
| + | ||
| + |  | |
| + | ||
| + | `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 | |
| + | ||
| + |  | |
| + | ||
| + | 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. | |
| + | ``` |
| @@ -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. |
| @@ -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. |
| @@ -0,0 +1,3 @@ | ||
| + | module github.com/zionboggan/agent-time-ledger | |
| + | ||
| + | go 1.19 |
| @@ -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) | |
| + | } |
| @@ -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, " ") | |
| + | } |
| @@ -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) | |
| + | } | |
| + | } |
| @@ -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() | |
| + | } |
| @@ -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 | |
| + | } |
| @@ -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, ¶ms); 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 | |
| + | } |
| @@ -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) | |
| + | } |