| @@ -46,6 +46,7 @@ go build -o atl ./cmd/atl | ||
| ```bash | ||
| atl now | ||
| atl now --json | ||
| + | atl now --tz America/Chicago --json | |
| atl session start --name build | ||
| atl session status | ||
| @@ -68,6 +69,8 @@ atl report --json | ||
| atl serve-mcp | ||
| ``` | ||
| + | `atl now` returns both a timezone-aware timestamp and a UTC timestamp. By default it uses the host timezone, or `ATL_TIMEZONE` if set. Pass `--tz <iana-timezone>` for an explicit zone such as `America/Chicago`, `America/New_York`, or `UTC`. | |
| + | ||
| 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 | ||
| @@ -82,7 +85,7 @@ JSON responses include a `confidence` value. For v0.1, persisted elapsed calcula | ||
| - `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. | |
| + | The `time_now` MCP tool accepts an optional `timezone` argument. 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 | ||
| @@ -97,6 +100,7 @@ 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 answering what time it is for a person, call time_now with their IANA timezone when known. | |
| Before relying on previous external observations, call stale_check. | ||
| When starting a long-running task, call mark_start with a descriptive name. | ||
| ``` |
| @@ -11,12 +11,14 @@ 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 now --tz America/Chicago --json | |
| ATL_HOME="$(mktemp -d)" /tmp/atl-smoke serve-mcp | ||
| ``` | ||
| Covered behavior: | ||
| - RFC3339 timestamp output | ||
| + | - timezone-aware `now` output with UTC offset | |
| - human duration formatting | ||
| - TTL parsing for `15s`, `30m`, `2h`, and `1d` | ||
| - stale checks before and after TTL expiry |
| @@ -35,15 +35,26 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer, service *ledg | ||
| switch args[0] { | ||
| case "now": | ||
| - | jsonOut, err := parseJSONFlag(args[1:]) | |
| + | jsonOut, rest, err := extractJSONFlag(args[1:]) | |
| + | if err != nil { | |
| + | return err | |
| + | } | |
| + | timezone, ok := optionalStringFlag(rest, "--tz") | |
| + | if !ok { | |
| + | timezone, _ = optionalStringFlag(rest, "--timezone") | |
| + | } | |
| + | rest = removeStringFlag(rest, "--tz", "--timezone") | |
| + | if len(rest) != 0 { | |
| + | return fmt.Errorf("unexpected argument %q", rest[0]) | |
| + | } | |
| + | response, err := service.NowResponseIn(timezone) | |
| if err != nil { | ||
| return err | ||
| } | ||
| - | response := service.NowResponse() | |
| if jsonOut { | ||
| return writeJSON(stdout, response) | ||
| } | ||
| - | fmt.Fprintln(stdout, response.Timestamp) | |
| + | fmt.Fprintf(stdout, "%s (%s, UTC%s)\nUTC: %s\n", response.Timestamp, response.Timezone, response.UTCOffset, response.UTCTimestamp) | |
| return nil | ||
| case "session": | ||
| return runSession(args[1:], stdout, service) | ||
| @@ -234,7 +245,7 @@ func runReport(args []string, stdout io.Writer, service *ledger.Service) error { | ||
| func printUsage(w io.Writer) { | ||
| fmt.Fprintln(w, `Usage: | ||
| - | atl now [--json] | |
| + | atl now [--json] [--tz <iana-timezone>] | |
| atl session start --name <name> | ||
| atl session status [--json] | ||
| atl session end | ||
| @@ -291,6 +302,32 @@ func optionalStringFlag(args []string, name string) (string, bool) { | ||
| return "", false | ||
| } | ||
| + | func removeStringFlag(args []string, names ...string) []string { | |
| + | nameSet := map[string]struct{}{} | |
| + | for _, name := range names { | |
| + | nameSet[name] = struct{}{} | |
| + | } | |
| + | rest := make([]string, 0, len(args)) | |
| + | for i := 0; i < len(args); i++ { | |
| + | arg := args[i] | |
| + | if _, ok := nameSet[arg]; ok { | |
| + | i++ | |
| + | continue | |
| + | } | |
| + | removed := false | |
| + | for name := range nameSet { | |
| + | if strings.HasPrefix(arg, name+"=") { | |
| + | removed = true | |
| + | break | |
| + | } | |
| + | } | |
| + | if !removed { | |
| + | rest = append(rest, arg) | |
| + | } | |
| + | } | |
| + | return rest | |
| + | } | |
| + | ||
| func writeJSON(w io.Writer, value any) error { | ||
| encoder := json.NewEncoder(w) | ||
| encoder.SetIndent("", " ") |
| @@ -26,6 +26,47 @@ func FormatRFC3339(t time.Time) string { | ||
| return t.UTC().Format(time.RFC3339Nano) | ||
| } | ||
| + | func FormatRFC3339In(t time.Time, location *time.Location) string { | |
| + | return t.In(location).Format(time.RFC3339Nano) | |
| + | } | |
| + | ||
| + | func LoadLocation(name string) (*time.Location, error) { | |
| + | switch strings.TrimSpace(strings.ToLower(name)) { | |
| + | case "", "local": | |
| + | return time.Local, nil | |
| + | case "utc", "z": | |
| + | return time.UTC, nil | |
| + | default: | |
| + | location, err := time.LoadLocation(name) | |
| + | if err != nil { | |
| + | return nil, fmt.Errorf("invalid timezone %q: %w", name, err) | |
| + | } | |
| + | return location, nil | |
| + | } | |
| + | } | |
| + | ||
| + | func LocationName(location *time.Location) string { | |
| + | if location == nil { | |
| + | return "UTC" | |
| + | } | |
| + | if location == time.Local { | |
| + | return "Local" | |
| + | } | |
| + | return location.String() | |
| + | } | |
| + | ||
| + | func UTCOffset(t time.Time) string { | |
| + | _, offset := t.Zone() | |
| + | sign := "+" | |
| + | if offset < 0 { | |
| + | sign = "-" | |
| + | offset = -offset | |
| + | } | |
| + | hours := offset / 3600 | |
| + | minutes := (offset % 3600) / 60 | |
| + | return fmt.Sprintf("%s%02d:%02d", sign, hours, minutes) | |
| + | } | |
| + | ||
| func ParseRFC3339(value string) (time.Time, error) { | ||
| t, err := time.Parse(time.RFC3339Nano, value) | ||
| if err != nil { |
| @@ -42,3 +42,14 @@ func TestRFC3339RoundTrip(t *testing.T) { | ||
| t.Fatalf("round trip = %s, want %s", got, now) | ||
| } | ||
| } | ||
| + | ||
| + | func TestLoadLocationAndOffset(t *testing.T) { | |
| + | location, err := LoadLocation("America/Chicago") | |
| + | if err != nil { | |
| + | t.Fatalf("LoadLocation returned error: %v", err) | |
| + | } | |
| + | now := time.Date(2026, 6, 19, 6, 30, 0, 0, time.UTC).In(location) | |
| + | if got := UTCOffset(now); got != "-05:00" { | |
| + | t.Fatalf("UTCOffset = %q, want -05:00", got) | |
| + | } | |
| + | } |
| @@ -2,6 +2,7 @@ package ledger | ||
| import ( | ||
| "fmt" | ||
| + | "os" | |
| "time" | ||
| "github.com/zionboggan/agent-time-ledger/internal/clock" | ||
| @@ -9,15 +10,18 @@ import ( | ||
| ) | ||
| type Service struct { | ||
| - | Store *state.Store | |
| - | Now func() time.Time | |
| + | Store *state.Store | |
| + | Now func() time.Time | |
| + | DefaultTimezone string | |
| } | ||
| type NowResponse struct { | ||
| - | Timestamp string `json:"timestamp"` | |
| - | Unix int64 `json:"unix"` | |
| - | Timezone string `json:"timezone"` | |
| - | Confidence string `json:"confidence"` | |
| + | Timestamp string `json:"timestamp"` | |
| + | UTCTimestamp string `json:"utc_timestamp"` | |
| + | Unix int64 `json:"unix"` | |
| + | Timezone string `json:"timezone"` | |
| + | UTCOffset string `json:"utc_offset"` | |
| + | Confidence string `json:"confidence"` | |
| } | ||
| type SessionStatusResponse struct { | ||
| @@ -72,17 +76,31 @@ type ReportResponse struct { | ||
| } | ||
| func NewService(store *state.Store) *Service { | ||
| - | return &Service{Store: store, Now: clock.Now} | |
| + | return &Service{Store: store, Now: clock.Now, DefaultTimezone: os.Getenv("ATL_TIMEZONE")} | |
| } | ||
| - | func (s *Service) NowResponse() NowResponse { | |
| + | func (s *Service) NowResponse() (NowResponse, error) { | |
| + | return s.NowResponseIn("") | |
| + | } | |
| + | ||
| + | func (s *Service) NowResponseIn(timezone string) (NowResponse, error) { | |
| + | if timezone == "" { | |
| + | timezone = s.DefaultTimezone | |
| + | } | |
| + | location, err := clock.LoadLocation(timezone) | |
| + | if err != nil { | |
| + | return NowResponse{}, err | |
| + | } | |
| now := s.now() | ||
| + | localNow := now.In(location) | |
| return NowResponse{ | ||
| - | Timestamp: clock.FormatRFC3339(now), | |
| - | Unix: now.Unix(), | |
| - | Timezone: "UTC", | |
| - | Confidence: clock.ConfidenceHostClock, | |
| - | } | |
| + | Timestamp: clock.FormatRFC3339In(now, location), | |
| + | UTCTimestamp: clock.FormatRFC3339(now), | |
| + | Unix: now.Unix(), | |
| + | Timezone: clock.LocationName(location), | |
| + | UTCOffset: clock.UTCOffset(localNow), | |
| + | Confidence: clock.ConfidenceHostClock, | |
| + | }, nil | |
| } | ||
| func (s *Service) StartSession(name string) (SessionStatusResponse, error) { |
| @@ -13,15 +13,41 @@ import ( | ||
| func TestNowResponseIsRFC3339(t *testing.T) { | ||
| service := testService(t, time.Date(2026, 6, 19, 6, 30, 0, 0, time.UTC)) | ||
| - | response := service.NowResponse() | |
| + | response, err := service.NowResponse() | |
| + | if err != nil { | |
| + | t.Fatalf("NowResponse returned error: %v", err) | |
| + | } | |
| if _, err := time.Parse(time.RFC3339Nano, response.Timestamp); err != nil { | ||
| t.Fatalf("timestamp %q is not RFC3339: %v", response.Timestamp, err) | ||
| } | ||
| + | if _, err := time.Parse(time.RFC3339Nano, response.UTCTimestamp); err != nil { | |
| + | t.Fatalf("utc timestamp %q is not RFC3339: %v", response.UTCTimestamp, err) | |
| + | } | |
| if response.Confidence != "host_clock" { | ||
| t.Fatalf("confidence = %q, want host_clock", response.Confidence) | ||
| } | ||
| } | ||
| + | func TestNowResponseTimezone(t *testing.T) { | |
| + | service := testService(t, time.Date(2026, 6, 19, 6, 30, 0, 0, time.UTC)) | |
| + | response, err := service.NowResponseIn("America/Chicago") | |
| + | if err != nil { | |
| + | t.Fatalf("NowResponseIn returned error: %v", err) | |
| + | } | |
| + | if response.Timezone != "America/Chicago" { | |
| + | t.Fatalf("timezone = %q, want America/Chicago", response.Timezone) | |
| + | } | |
| + | if response.UTCOffset != "-05:00" { | |
| + | t.Fatalf("utc offset = %q, want -05:00", response.UTCOffset) | |
| + | } | |
| + | if response.Timestamp != "2026-06-19T01:30:00-05:00" { | |
| + | t.Fatalf("timestamp = %q, want Central daylight timestamp", response.Timestamp) | |
| + | } | |
| + | if response.UTCTimestamp != "2026-06-19T06:30:00Z" { | |
| + | t.Fatalf("utc timestamp = %q, want 2026-06-19T06:30:00Z", response.UTCTimestamp) | |
| + | } | |
| + | } | |
| + | ||
| func TestStaleCheckFreshBeforeTTL(t *testing.T) { | ||
| now := time.Date(2026, 6, 19, 6, 30, 0, 0, time.UTC) | ||
| service := testService(t, now) |
| @@ -27,6 +27,11 @@ type response struct { | ||
| Error *rpcError `json:"error,omitempty"` | ||
| } | ||
| + | type message struct { | |
| + | Body []byte | |
| + | Framed bool | |
| + | } | |
| + | ||
| type rpcError struct { | ||
| Code int `json:"code"` | ||
| Message string `json:"message"` | ||
| @@ -37,6 +42,10 @@ type toolCallParams struct { | ||
| Arguments json.RawMessage `json:"arguments,omitempty"` | ||
| } | ||
| + | type initializeParams struct { | |
| + | ProtocolVersion string `json:"protocolVersion"` | |
| + | } | |
| + | ||
| type callResult struct { | ||
| Content []content `json:"content"` | ||
| IsError bool `json:"isError,omitempty"` | ||
| @@ -50,7 +59,7 @@ type content struct { | ||
| func Serve(stdin io.Reader, stdout, stderr io.Writer, service *ledger.Service) error { | ||
| reader := bufio.NewReader(stdin) | ||
| for { | ||
| - | body, err := readMessage(reader) | |
| + | msg, err := readMessage(reader) | |
| if err == io.EOF { | ||
| return nil | ||
| } | ||
| @@ -58,13 +67,13 @@ func Serve(stdin io.Reader, stdout, stderr io.Writer, service *ledger.Service) e | ||
| fmt.Fprintln(stderr, err) | ||
| return err | ||
| } | ||
| - | if len(bytes.TrimSpace(body)) == 0 { | |
| + | if len(bytes.TrimSpace(msg.Body)) == 0 { | |
| continue | ||
| } | ||
| var req request | ||
| - | if err := json.Unmarshal(body, &req); err != nil { | |
| - | _ = writeMessage(stdout, response{ | |
| + | if err := json.Unmarshal(msg.Body, &req); err != nil { | |
| + | _ = writeMessage(stdout, msg.Framed, response{ | |
| JSONRPC: "2.0", | ||
| Error: &rpcError{Code: -32700, Message: "parse error"}, | ||
| }) | ||
| @@ -74,7 +83,7 @@ func Serve(stdin io.Reader, stdout, stderr io.Writer, service *ledger.Service) e | ||
| continue | ||
| } | ||
| resp := handle(req, service) | ||
| - | if err := writeMessage(stdout, resp); err != nil { | |
| + | if err := writeMessage(stdout, msg.Framed, resp); err != nil { | |
| return err | ||
| } | ||
| } | ||
| @@ -84,16 +93,23 @@ func handle(req request, service *ledger.Service) response { | ||
| resp := response{JSONRPC: "2.0", ID: req.ID} | ||
| switch req.Method { | ||
| case "initialize": | ||
| + | protocolVersion := "2024-11-05" | |
| + | var params initializeParams | |
| + | if len(req.Params) > 0 && json.Unmarshal(req.Params, ¶ms) == nil && params.ProtocolVersion != "" { | |
| + | protocolVersion = params.ProtocolVersion | |
| + | } | |
| resp.Result = map[string]any{ | ||
| - | "protocolVersion": "2024-11-05", | |
| + | "protocolVersion": protocolVersion, | |
| "capabilities": map[string]any{ | ||
| - | "tools": map[string]any{}, | |
| + | "tools": map[string]any{"listChanged": false}, | |
| }, | ||
| "serverInfo": map[string]any{ | ||
| "name": "agent-time-ledger", | ||
| "version": "0.1.0", | ||
| }, | ||
| } | ||
| + | case "ping": | |
| + | resp.Result = map[string]any{} | |
| case "tools/list": | ||
| resp.Result = map[string]any{"tools": tools()} | ||
| case "tools/call": | ||
| @@ -106,6 +122,10 @@ func handle(req request, service *ledger.Service) response { | ||
| } else { | ||
| resp.Result = result | ||
| } | ||
| + | case "resources/list": | |
| + | resp.Result = map[string]any{"resources": []any{}} | |
| + | case "prompts/list": | |
| + | resp.Result = map[string]any{"prompts": []any{}} | |
| default: | ||
| resp.Error = &rpcError{Code: -32601, Message: "method not found"} | ||
| } | ||
| @@ -128,7 +148,8 @@ func handleToolCall(raw json.RawMessage, service *ledger.Service) (callResult, e | ||
| var err error | ||
| switch params.Name { | ||
| case "time_now": | ||
| - | result = service.NowResponse() | |
| + | timezone, _ := args["timezone"].(string) | |
| + | result, err = service.NowResponseIn(timezone) | |
| case "session_status": | ||
| result, err = service.SessionStatus() | ||
| case "mark_start": | ||
| @@ -180,7 +201,9 @@ func requiredString(args map[string]any, name string) string { | ||
| func tools() []map[string]any { | ||
| return []map[string]any{ | ||
| - | tool("time_now", "Return the host clock time.", map[string]any{}), | |
| + | tool("time_now", "Return the current time with timezone and UTC offset.", map[string]any{ | |
| + | "timezone": stringSchema("Optional IANA timezone, such as America/Chicago, America/New_York, or UTC"), | |
| + | }), | |
| 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"), | ||
| @@ -220,31 +243,31 @@ func stringSchema(description string) map[string]any { | ||
| return map[string]any{"type": "string", "description": description} | ||
| } | ||
| - | func readMessage(reader *bufio.Reader) ([]byte, error) { | |
| + | func readMessage(reader *bufio.Reader) (message, error) { | |
| line, err := reader.ReadString('\n') | ||
| if err != nil { | ||
| if err == io.EOF && strings.TrimSpace(line) != "" { | ||
| - | return []byte(line), nil | |
| + | return message{Body: []byte(line)}, nil | |
| } | ||
| - | return nil, err | |
| + | return message{}, err | |
| } | ||
| line = strings.TrimRight(line, "\r\n") | ||
| if strings.TrimSpace(line) == "" { | ||
| - | return []byte{}, nil | |
| + | return message{}, nil | |
| } | ||
| if !strings.HasPrefix(strings.ToLower(line), "content-length:") { | ||
| - | return []byte(line), nil | |
| + | return message{Body: []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) | |
| + | return message{}, fmt.Errorf("invalid Content-Length: %w", err) | |
| } | ||
| for { | ||
| header, err := reader.ReadString('\n') | ||
| if err != nil { | ||
| - | return nil, err | |
| + | return message{}, err | |
| } | ||
| if strings.TrimSpace(header) == "" { | ||
| break | ||
| @@ -252,14 +275,18 @@ func readMessage(reader *bufio.Reader) ([]byte, error) { | ||
| } | ||
| body := make([]byte, length) | ||
| _, err = io.ReadFull(reader, body) | ||
| - | return body, err | |
| + | return message{Body: body, Framed: true}, err | |
| } | ||
| - | func writeMessage(w io.Writer, value any) error { | |
| + | func writeMessage(w io.Writer, framed bool, 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) | |
| + | if framed { | |
| + | _, err = fmt.Fprintf(w, "Content-Length: %d\r\n\r\n%s", len(data), data) | |
| + | return err | |
| + | } | |
| + | _, err = fmt.Fprintf(w, "%s\n", data) | |
| return err | ||
| } |