diff --git a/.kiro/specs/natural-language-time/.config.kiro b/.kiro/specs/natural-language-time/.config.kiro new file mode 100644 index 0000000..822a7ca --- /dev/null +++ b/.kiro/specs/natural-language-time/.config.kiro @@ -0,0 +1 @@ +{"specId": "0b3eeac1-e28d-4bed-a76f-0c3388610c53", "workflowType": "requirements-first", "specType": "feature"} diff --git a/.kiro/specs/natural-language-time/requirements.md b/.kiro/specs/natural-language-time/requirements.md new file mode 100644 index 0000000..89b2061 --- /dev/null +++ b/.kiro/specs/natural-language-time/requirements.md @@ -0,0 +1,170 @@ +# Requirements Document + +## Introduction + +The `meeting-alerter` CLI in this repository accepts a calendar window via the `--minutes`, `--day`, `--from`, and `--to` flags. The accepted inputs are strict: integer minutes, `today` / `tomorrow` / `YYYY-MM-DD` for `--day`, and RFC3339-ish stamps for `--from` / `--to`. The user wants to be able to express a window in plain English from the terminal — phrases like `3 days from now`, `this week`, `monday through friday`, `next 4 hours`, and `rest of the week`. + +This spec adds a hand-rolled natural-language time parser at `internal/whenparse`, plumbs it through the existing `meeting-alerter list` flags, and introduces a new `--when` flag that accepts a range phrase as a single argument. The parser is intentionally bounded — a closed grammar of phrases the user actually types — and lives behind a small interface so a smarter implementation (e.g. an LLM-backed fallback) can be slotted in later without changing callers. Initial scope is `meeting-alerter`; `work-cal-sync` is a candidate consumer once the package is stable. + +The parser must be hand-rolled with no new module dependencies (we evaluated `github.com/markusmobius/go-dateparser` and concluded the binary cost and lack of range support did not justify it). All parsing happens in `time.Local`. Week boundaries are Sunday-anchored to match the user's iCloud configuration. + +## Glossary + +- **whenparse**: The new Go package at `internal/whenparse` that exposes `ParseInstant` and `ParseRange`. +- **Instant**: A single point in time, returned as `time.Time` in `time.Local`. +- **Range**: A half-open `[start, end)` interval expressed as two `time.Time` values in `time.Local`. +- **Day_Range**: A range whose start is local midnight of day D and whose end is local midnight of day D+1. +- **Week_Range**: A range that begins at local midnight on a Sunday and ends at local midnight on the following Sunday (seven full local days). +- **ParseInstant**: The function `whenparse.ParseInstant(s string, now time.Time) (time.Time, error)`. +- **ParseRange**: The function `whenparse.ParseRange(s string, now time.Time) (start, end time.Time, error)`. +- **Range_Provider**: The interface satisfied by `ParseRange` (`func(s string, now time.Time) (time.Time, time.Time, error)`) that downstream callers depend on. The hand-rolled parser is the default implementation. +- **meeting-alerter**: The CLI at `cmd/meeting-alerter` that consumes the parser via its `list` subcommand flags. +- **list_subcommand**: The `meeting-alerter list` subcommand and its flag parser (currently `parseListWindowAt` in `cmd/meeting-alerter/main.go`). +- **Window_Flags**: The flag set on `list_subcommand` covering `--minutes`, `--day`, `--from`, `--to`, and (new) `--when`. +- **Weekday_Name**: A case-insensitive English weekday word: `monday`, `tuesday`, `wednesday`, `thursday`, `friday`, `saturday`, `sunday`. +- **Unit_Word**: A case-insensitive duration unit: `minute`/`minutes`, `hour`/`hours`, `day`/`days`, `week`/`weeks`. +- **Number_Word**: A small English number: `one` through `ten`, treated as the integers 1 through 10. Numeric digits are also accepted. + +## Requirements + +### Requirement 1: Instant Parser + +**User Story:** As a CLI user, I want to type a single point in time as plain English, so that I can pass it to `--from` or `--to` without remembering RFC3339. + +#### Acceptance Criteria + +1. WHEN `ParseInstant("now", now)` is called, THE whenparse SHALL return a time equal to `now`. +2. WHEN `ParseInstant("today", now)` is called, THE whenparse SHALL return local midnight at the start of `now`'s calendar day. +3. WHEN `ParseInstant("tomorrow", now)` is called, THE whenparse SHALL return local midnight at the start of the day after `now`. +4. WHEN `ParseInstant("yesterday", now)` is called, THE whenparse SHALL return local midnight at the start of the day before `now`. +5. WHEN the input matches `in N ` or `N from now` (where `N` is a positive integer or `Number_Word`), THE whenparse SHALL return `now` plus N units. +6. WHEN the input matches `N ago`, THE whenparse SHALL return `now` minus N units. +7. WHEN the input is a `Weekday_Name`, THE whenparse SHALL return local midnight of the next occurrence of that weekday strictly after `now`'s calendar day. +8. WHEN the input matches `today at ` or `tomorrow at ` (where time-of-day is `HH:MM` 24-hour or `H[:MM][am|pm]` 12-hour), THE whenparse SHALL return that wall-clock time on the corresponding local day. +9. WHEN the input is `YYYY-MM-DD`, THE whenparse SHALL return local midnight at the start of that date. +10. WHEN the input is `YYYY-MM-DDTHH:MM` or `YYYY-MM-DD HH:MM` without a zone, THE whenparse SHALL return that wall-clock time interpreted in `time.Local`. +11. WHEN the input is a full RFC3339 timestamp, THE whenparse SHALL return the time it represents converted to `time.Local`. +12. THE whenparse SHALL accept input with surrounding whitespace and SHALL match keywords case-insensitively. +13. IF the input does not match any supported instant phrase, THEN THE whenparse SHALL return a non-nil error whose message names the input and lists at least one alternative phrasing. + +### Requirement 2: Range Parser + +**User Story:** As a CLI user, I want to express a calendar window as a single phrase, so that I can ask `meeting-alerter list --when "this week"` instead of computing two timestamps. + +#### Acceptance Criteria + +1. WHEN the input is a phrase that `ParseInstant` resolves to a calendar day (`today`, `tomorrow`, `yesterday`, a `Weekday_Name`, or `YYYY-MM-DD`), THE whenparse SHALL return a `Day_Range` covering that local day. +2. WHEN the input matches `next N ` (with N a positive integer or `Number_Word`), THE whenparse SHALL return `[now, now + N units)` and SHALL NOT round to a day boundary. +3. WHEN the input is `this week`, THE whenparse SHALL return the `Week_Range` containing `now`. +4. WHEN the input is `next week`, THE whenparse SHALL return the `Week_Range` immediately after the one containing `now`. +5. WHEN the input is `last week`, THE whenparse SHALL return the `Week_Range` immediately before the one containing `now`. +6. WHEN the input is `rest of the week`, THE whenparse SHALL return `[now, end_of_saturday)` where `end_of_saturday` is local midnight at the start of the Sunday that ends `now`'s `Week_Range`. +7. WHEN the input matches ` through `, THE whenparse SHALL return `[start_of_A, end_of_B)` where both endpoints are anchored to the `Week_Range` containing `now` (with `start_of_A` snapped to the Sunday-anchored start and `end_of_B` snapped to the Saturday-anchored end of that week). +8. WHEN the input matches `until ` or `until `, THE whenparse SHALL return `[now, end_of_that_day)` where `end_of_that_day` is local midnight at the start of the day after the named day. +9. WHEN the input matches ` through `, THE whenparse SHALL parse each side via `ParseInstant` and return `[A, B)`. +10. IF a successfully parsed range has `end <= start`, THEN THE whenparse SHALL return a non-nil error identifying the offending phrase. +11. IF the input does not match any supported range phrase, THEN THE whenparse SHALL return a non-nil error whose message names the input and lists at least one alternative phrasing. +12. THE whenparse SHALL accept input with surrounding whitespace and SHALL match keywords case-insensitively. + +### Requirement 3: List Subcommand Flag Plumbing + +**User Story:** As a `meeting-alerter` user, I want the existing `--from` / `--to` / `--day` flags to accept the new phrases plus a new `--when` flag for full ranges, so that I have one consistent vocabulary for time windows. + +#### Acceptance Criteria + +1. WHEN `--from ` is supplied to `list_subcommand`, THE list_subcommand SHALL resolve `` via `ParseInstant` and use the result as the window start. +2. WHEN `--to ` is supplied to `list_subcommand`, THE list_subcommand SHALL resolve `` via `ParseInstant` and use the result as the window end. +3. WHEN `--day ` is supplied to `list_subcommand`, THE list_subcommand SHALL resolve `` via `ParseRange` and use the resulting `[start, end)` as the window. +4. WHEN `--when ` is supplied to `list_subcommand`, THE list_subcommand SHALL resolve `` via `ParseRange` and use the resulting `[start, end)` as the window. +5. WHEN `--minutes N` is supplied to `list_subcommand`, THE list_subcommand SHALL require `N` to be a non-negative integer literal and SHALL reject any non-integer value with an error. +6. WHEN no `Window_Flags` are supplied, THE list_subcommand SHALL use `[now, now + cfg.LookaheadMinutes)` as the window (preserving existing default behavior). +7. IF the resolved window has `end <= start` after resolving any combination of `Window_Flags`, THEN THE list_subcommand SHALL exit with a non-zero status and SHALL print an error identifying the offending flag values. + +### Requirement 4: Mutual Exclusion Between Window Flags + +**User Story:** As a `meeting-alerter` user, I want clear feedback when I combine flags that disagree, so that I don't get a silently wrong window. + +#### Acceptance Criteria + +1. IF `--minutes` is supplied together with any of `--from`, `--to`, `--day`, or `--when`, THEN THE list_subcommand SHALL exit with a non-zero status and SHALL print an error naming the conflicting flags. +2. IF `--day` is supplied together with `--from`, `--to`, or `--when`, THEN THE list_subcommand SHALL exit with a non-zero status and SHALL print an error naming the conflicting flags. +3. IF `--when` is supplied together with `--from` or `--to`, THEN THE list_subcommand SHALL exit with a non-zero status and SHALL print an error naming the conflicting flags. +4. IF exactly one of `--from` and `--to` is supplied (without `--day`, `--when`, or `--minutes`), THEN THE list_subcommand SHALL exit with a non-zero status and SHALL print an error stating that `--from` and `--to` must be supplied together. +5. WHEN `--from` and `--to` are supplied together (without other `Window_Flags`), THE list_subcommand SHALL accept the combination. + +### Requirement 5: Actionable Error Messages + +**User Story:** As a CLI user, I want a parse failure to tell me what I typed and suggest a phrase that would have worked, so that I can fix my command without consulting docs. + +#### Acceptance Criteria + +1. WHEN `ParseInstant` returns an error, THE error message SHALL include the raw input string in quotes. +2. WHEN `ParseRange` returns an error, THE error message SHALL include the raw input string in quotes. +3. WHEN `ParseInstant` returns an error, THE error message SHALL include at least one example of a supported instant phrase. +4. WHEN `ParseRange` returns an error, THE error message SHALL include at least one example of a supported range phrase. +5. WHEN `list_subcommand` rejects a `Window_Flags` value, THE error message printed to stderr SHALL include the flag name, the offending value, and an example of valid input. + +### Requirement 6: Test Coverage + +**User Story:** As a maintainer, I want a single table-driven test suite that exercises every documented phrase, so that regressions in the parser are caught immediately. + +#### Acceptance Criteria + +1. THE whenparse SHALL include a table-driven test that pins `now` to a fixed local timestamp and SHALL contain at least 30 distinct phrase cases covering each acceptance criterion in Requirements 1 and 2. +2. WHEN a test case in the table specifies an expected `[start, end)`, THE test SHALL assert that both bounds match exactly. +3. WHEN a test case in the table specifies an expected error, THE test SHALL assert that an error is returned and that the error message contains the expected substring. +4. THE whenparse SHALL include cases that exercise every Requirement 1 acceptance criterion (instants) and every Requirement 2 acceptance criterion (ranges). +5. THE list_subcommand tests SHALL include at least one case per mutual-exclusion rule in Requirement 4. + +### Requirement 7: Documentation Updates + +**User Story:** As a new user, I want the README and user guide to show natural-language examples first, so that I learn the friendly syntax before the strict one. + +#### Acceptance Criteria + +1. THE `docs/user-guide.md` SHALL include a section showing at least the eight phrase examples called out in the spec request (`3 days from now`, `two days from now through the end of the week`, `tomorrow` paired with `three days from now`, `this week`, `monday through friday`, `next 4 hours`, `rest of the week`, `until friday`). +2. THE `cmd/meeting-alerter/main.go` `printUsage` text and `listUsage` constant SHALL document `--when` and SHALL describe `--from`, `--to`, and `--day` as accepting natural-language phrases. +3. THE updated documentation SHALL state that `--minutes` remains integer-only. +4. THE updated documentation SHALL state that all parsing happens in the local time zone and that week boundaries are Sunday-anchored. + +### Requirement 8: Dependency and Platform Constraints + +**User Story:** As the project maintainer, I want this feature implemented without growing the dependency surface, so that the binary stays small and the build stays simple. + +#### Acceptance Criteria + +1. THE whenparse SHALL be implemented using only the Go standard library. +2. THE `go.mod` and `go.sum` SHALL NOT gain any new direct or transitive dependencies as a result of this change. +3. THE whenparse SHALL build and pass tests on macOS (the only supported platform for this repository). +4. THE whenparse SHALL perform all time arithmetic in `time.Local` and SHALL anchor week boundaries to Sunday. + +### Requirement 9: Range_Provider Interface for Future Extension + +**User Story:** As the future-me adding an LLM-backed fallback, I want callers to depend on an interface rather than the concrete parser, so that I can swap in a smarter implementation without touching call sites. + +#### Acceptance Criteria + +1. THE whenparse SHALL expose a `Range_Provider` function type whose signature matches `ParseRange`. +2. THE whenparse SHALL expose an `Instant_Provider` function type whose signature matches `ParseInstant`. +3. THE list_subcommand SHALL accept its parser dependencies as values of these function types (defaulting to `whenparse.ParseRange` and `whenparse.ParseInstant`) so tests and future callers can substitute alternative implementations. + +## TODO / Future Direction + +The following items are explicitly out of scope for this spec and are listed here so they are not lost when the implementation lands. + +### TODO-1: Reusable Kiro Skill + +Package the natural-language parser as a reusable Kiro skill (or equivalent shared module) so any other tool in this workspace can call it without copying code or duplicating the grammar. The skill would expose `ParseInstant` and `ParseRange` over whatever transport the host provides, and would carry the same documentation and test fixtures as the Go package. + +### TODO-2: LLM-Backed Higher-Tier Fallback + +Add an LLM-backed implementation of `Range_Provider` (and `Instant_Provider`) that runs only when the hand-rolled parser returns an error. The LLM would interpret novel phrasings such as "the day before my anniversary" or "before lunch" and return a structured `{start, end}` JSON, which the host would then validate and convert to `time.Time` values. Constraints to design toward: + +- The LLM call SHALL be opt-in (config flag or env var) so the default build has no network dependency. +- The LLM call SHALL be cached by `(input, now-truncated-to-minute)` so repeated invocations do not re-query. +- The LLM response SHALL be validated against the same `end > start` invariant the hand-rolled parser enforces before being handed to callers. +- The LLM implementation SHALL be a drop-in `Range_Provider` so the rest of the system does not need to know whether a phrase was resolved locally or remotely. + +### TODO-3: `work-cal-sync` Adoption + +Once the parser is stable in `meeting-alerter`, plumb `--from` / `--to` / `--when` into `cmd/work-cal-sync` (e.g. for a future ad-hoc range-sync mode) so both CLIs share the same vocabulary.