● Zion Boggan zionboggan.com ↗

Add timezone-aware time output

6bd84cf   Zion Boggan committed on Jun 19, 2026 (2 months ago)
README.md +5 -1
@@ -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.
```
docs/verification.md +2 -0
@@ -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
internal/cli/cli.go +41 -4
@@ -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("", " ")
internal/clock/clock.go +41 -0
@@ -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 {
internal/clock/clock_test.go +11 -0
@@ -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)
+ }
+}
internal/ledger/ledger.go +31 -13
@@ -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) {
internal/ledger/ledger_test.go +27 -1
@@ -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)
internal/mcp/mcp.go +46 -19
@@ -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, &params) == 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
}