Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
6ea0c13
feat(store): item reminders — the fire-at-an-instant primitive (IDEA-…
xarmian Sep 3, 2026
9f1d26d
feat(server): reminder surfaces, and one shared overdue rule for all …
xarmian Sep 3, 2026
81e2472
test(reminders): the lifecycle, the four surfaces, and 22 killed mutants
xarmian Sep 3, 2026
e68c241
feat(mcp): pad_item.remind + ack-reminder, ToolSurfaceVersion 0.28
xarmian Sep 3, 2026
d019608
fix(reminders): codex round 1 — four findings, all real, all with a pin
xarmian Sep 3, 2026
0265607
fix(reminders): codex round 2 — four findings, all real
xarmian Sep 3, 2026
bd31c14
docs(reminders): the ack id is on the surface an agent polls, not onl…
xarmian Sep 3, 2026
c3df3a6
test(reminders): bind the tick LOOP to the work, not just the pass (C…
xarmian Sep 3, 2026
5abedec
fix(reminders): codex round 3 — a deferred reminder fired anyway, and…
xarmian Sep 3, 2026
9696082
docs(reminders): the fire predicate arbitrates against two actors, no…
xarmian Sep 4, 2026
9903d35
fix(reminders): codex round 4 — the round-3 bound recreated the round…
xarmian Sep 4, 2026
e6161d1
fix(reminders): codex round 5 — the MCP action I shipped did not work…
xarmian Sep 4, 2026
0f06172
fix(reminders): codex round 6 — reminders fired from soft-deleted wor…
xarmian Sep 4, 2026
683e9d6
fix(reminders): codex round 7 — one predicate for the scan and the ar…
xarmian Sep 4, 2026
a747735
fix(reminders): codex round 8 — workspace export silently dropped eve…
xarmian Sep 4, 2026
50114d6
test(reminders): state the fire-path invariant and pin it from the in…
xarmian Sep 4, 2026
606258a
fix(reminders): codex round 9 — one legacy row could hide every reminder
xarmian Sep 4, 2026
e8e142d
fix(reminders): codex round 10 — one orphaned item aborted a whole re…
xarmian Sep 4, 2026
d3c1dd3
fix(reminders): codex round 11 — four contract slips, one of them ano…
xarmian Sep 4, 2026
90daee2
fix(reminders): codex round 12 — a read is not a hold; scope the arm;…
xarmian Sep 4, 2026
538c273
fix(reminders): codex round 13 — a reminder's workspace must agree wi…
xarmian Sep 4, 2026
93161db
fix(reminders): codex round 14 — the by-id and by-item reads assert t…
xarmian Sep 4, 2026
142c119
fix(reminders): codex round 16 — an archived item's reminders are rea…
xarmian Sep 4, 2026
110c9fe
fix(reminders): codex round 17 — one suggestion per item, the archive…
xarmian Sep 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions CLAUDE.md

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions README.md

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions cmd/pad/cmd_project.go
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,11 @@ func nextCmd() *cobra.Command {
// branch's framing.
var dash struct {
SuggestedNext []struct {
// ReminderID is present only on a fired-reminder
// suggestion, and it is the handle an ack needs — a
// surface that shows a reminder without it can be read
// and not acted on (IDEA-2641, codex round 1).
ReminderID string `json:"reminder_id,omitempty"`
ItemSlug string `json:"item_slug"`
ItemRef string `json:"item_ref,omitempty"`
ItemTitle string `json:"item_title"`
Expand Down Expand Up @@ -259,6 +264,9 @@ func nextCmd() *cobra.Command {
bold.Sprint(s.ItemTitle),
dim.Sprint(s.Reason),
)
if s.ReminderID != "" {
fmt.Printf(" %s\n", dim.Sprintf("acknowledge with: pad item ack %s", s.ReminderID))
}
}
return nil
},
Expand Down
198 changes: 198 additions & 0 deletions cmd/pad/cmd_reminder.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
package main

import (
"fmt"
"text/tabwriter"

"github.com/fatih/color"
"github.com/spf13/cobra"

"github.com/PerpetualSoftware/pad/internal/cli"
)

// `pad item remind` and friends — the CLI half of IDEA-2641 / GitHub #1010.
//
// The verbs mirror the lifecycle rather than inventing a vocabulary: arm
// (`remind`), see (`reminders`), move (`remind --rearm`), acknowledge (`ack`),
// disarm (`unremind`).

var (
remindAtFlag string
remindRearmID string
)

func remindCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "remind [ref]",
Short: "Arm a reminder on an item",
Long: `Arm a one-shot reminder that fires at a specific instant.

The instant is RFC3339 and must carry a time of day — 2026-08-01T09:00:00Z, or
2026-08-01T09:00:00-04:00, which is stored as the same moment in UTC. A bare
date is refused rather than assumed to mean midnight: "2026-08-01" names a
24-hour span, and picking an hour inside it would be Pad choosing a time you
did not and then firing at it.

When the reminder fires it appears in 'pad project next' and 'pad project
ready' until you acknowledge it with 'pad item ack', and it emits an
item.reminder_due webhook event. The poll surface is not optional: an instance
with no webhook configured delivers reminders that way and only that way.`,
// `[ref]` rather than `<ref>` in Use, because cmdhelp derives the
// machine-readable arg spec from this string and `<ref>` would declare
// a REQUIRED positional that --rearm does not take (codex round 6).
// The requirement is conditional, which cmdhelp has no way to express,
// so the honest declaration is "optional" plus the explicit check
// below that names the two ways to call it.
//
// MaximumNArgs, not ExactArgs: --rearm addresses a REMINDER by id and
// needs no item ref, so requiring one made the flag unusable (codex
// round 2). The two modes are checked below rather than merged,
// because a ref supplied alongside --rearm is ambiguous — it names an
// item the reminder may not even belong to — and silently ignoring it
// is how a user learns nothing about the reminder they just moved.
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
client, _ := getClient()
ws := getWorkspace()

if remindAtFlag == "" {
return fmt.Errorf("--remind-at is required (an RFC3339 instant, e.g. 2026-08-01T09:00:00Z)")
}

if remindRearmID != "" {
if len(args) > 0 {
return fmt.Errorf("--rearm addresses a reminder by id, so it takes no item ref (got %q)", args[0])
}
r, err := client.RearmReminder(ws, remindRearmID, remindAtFlag)
if err != nil {
return err
}
if formatFlag == "json" {
return cli.PrintJSON(r)
}
fmt.Printf("Re-armed reminder %s for %s\n", r.ID, r.RemindAt)
return nil
}

if len(args) == 0 {
return fmt.Errorf("an item ref is required (e.g. pad item remind TASK-5 --remind-at 2026-08-01T09:00:00Z), or use --rearm <id> to move an existing reminder")
}
item, err := client.GetItem(ws, args[0])
if err != nil {
return err
}
r, err := client.CreateItemReminder(ws, item.Slug, remindAtFlag)
if err != nil {
return err
}
if formatFlag == "json" {
return cli.PrintJSON(r)
}
fmt.Printf("Reminder armed on %s for %s (id %s)\n", item.Ref, r.RemindAt, r.ID)
return nil
},
}
cmd.Flags().StringVar(&remindAtFlag, "remind-at", "", "when to fire (RFC3339 instant, e.g. 2026-08-01T09:00:00Z)")
cmd.Flags().StringVar(&remindRearmID, "rearm", "", "move an existing reminder by id instead of arming a new one")
return cmd
}

func remindersCmd() *cobra.Command {
return &cobra.Command{
Use: "reminders <ref>",
Short: "Show an item's reminders",
Long: `List every reminder on an item — armed, fired, and acknowledged.

Fired reminders are kept rather than deleted: the row is the record that a
reminder existed and went out.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
client, _ := getClient()
ws := getWorkspace()

item, err := client.GetItem(ws, args[0])
if err != nil {
return err
}
reminders, err := client.ListItemReminders(ws, item.Slug)
if err != nil {
return err
}
if formatFlag == "json" {
return cli.PrintJSON(reminders)
}
if len(reminders) == 0 {
fmt.Printf("No reminders on %s.\n", item.Ref)
return nil
}

w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 4, 2, ' ', 0)
fmt.Fprintf(w, "ID\tWHEN\tSTATE\n")
for _, r := range reminders {
state := "armed"
switch {
case r.FiredAt != nil && r.AckedAt != nil:
state = "acknowledged"
case r.FiredAt != nil:
state = "FIRED — needs ack"
}
fmt.Fprintf(w, "%s\t%s\t%s\n", r.ID, r.RemindAt, state)
}
return w.Flush()
},
}
}

func ackCmd() *cobra.Command {
return &cobra.Command{
Use: "ack <reminder-id>",
Short: "Acknowledge a fired reminder",
Long: `Acknowledge a reminder that has fired, removing it from 'pad project next'.

Nothing else acknowledges a reminder. In particular, completing the item does
NOT: a reminder may have been armed precisely to fire after the work was done,
and consuming it on a status change would throw that away. A reminder on a
completed item is hidden from the recommendation surface but stays in the
table, still unacknowledged, exactly as you left it.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
client, _ := getClient()
ws := getWorkspace()

r, err := client.AckReminder(ws, args[0])
if err != nil {
return err
}
if formatFlag == "json" {
return cli.PrintJSON(r)
}
color.New(color.Faint).Printf("Acknowledged reminder %s\n", r.ID)
return nil
},
}
}

func unremindCmd() *cobra.Command {
return &cobra.Command{
Use: "unremind <reminder-id>",
Short: "Disarm a reminder",
Long: `Remove a reminder.

Deletion is the only disarm — there is no cancelled state, because a cancelled
reminder and an absent one are indistinguishable to everything that reads them.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
client, _ := getClient()
ws := getWorkspace()

if err := client.DeleteReminder(ws, args[0]); err != nil {
return err
}
if formatFlag == "json" {
return cli.PrintJSON(map[string]any{"id": args[0], "deleted": true})
}
fmt.Printf("Removed reminder %s\n", args[0])
return nil
},
}
}
9 changes: 9 additions & 0 deletions cmd/pad/cmd_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -868,6 +868,15 @@ func serveCmd() *cobra.Command {
}
srv.StartTokenReaper()

// Item reminder scheduler (IDEA-2641 / GitHub #1010). The only
// thing in Pad that ACTS at a target time rather than reporting
// on one when asked. Default: 30s, override-able via env
// (PAD_REMINDER_TICK_INTERVAL) the same way the reaper is.
if reminderInterval := parseDurationEnv("PAD_REMINDER_TICK_INTERVAL", 0); reminderInterval != 0 {
srv.SetReminderTickConfig(reminderInterval, 0)
}
srv.StartReminderTick()

// Workspace hard-purge sweeper (TASK-1966). Periodic sweep
// that hard-deletes workspaces soft-deleted more than 30 days
// ago — cascading every child row and reclaiming attachment
Expand Down
4 changes: 4 additions & 0 deletions cmd/pad/groups.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,10 @@ func itemCmd() *cobra.Command {
bulkUpdateCmd(),
commentCmd(),
commentsCmd(),
remindCmd(),
remindersCmd(),
ackCmd(),
unremindCmd(),
noteCmd(),
decideCmd(),
blocksCmd(),
Expand Down
7 changes: 7 additions & 0 deletions cmd/pad/query.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,13 @@ for active plans.`,
label := strings.TrimSpace(strings.Join([]string{s.ItemRef, s.ItemTitle}, " "))
fmt.Printf(" %s %s\n", dim.Sprintf("%d.", i+1), bold.Sprint(label))
fmt.Printf(" %s\n", dim.Sprint(s.Reason))
// The ack handle, same as `next` (codex round 5). Showing a
// fired reminder on the surface an agent polls and withholding
// the id it needs to retire it means the same entry comes back
// on every poll forever.
if s.ReminderID != "" {
fmt.Printf(" %s\n", dim.Sprintf("acknowledge with: pad item ack %s", s.ReminderID))
}
}
return nil
},
Expand Down
127 changes: 127 additions & 0 deletions cmd/pad/stale_overdue_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package main

import (
"testing"

"github.com/PerpetualSoftware/pad/internal/cmdhelp"

"github.com/PerpetualSoftware/pad/internal/server"
)

// The CLI half of IDEA-2641's stale leg.
//
// `pad project stale` does no date work of its own — it filters the
// dashboard's attention list and keeps four types. The server-side leg pins
// that an overdue item lands in that list carrying type "overdue"; this pins
// the other half, that stale still keeps it. Split across the two packages
// because that is where the two halves actually live: a single test could not
// fail for the CLI's reason.
//
// MUTANT: removing "overdue" from filterAgentAttention's interesting map makes
// deadlines vanish from `pad project stale` while every server-side assertion
// stays green.
func TestStaleKeepsOverdueAttention(t *testing.T) {
attention := []server.DashboardAttention{
{Type: "overdue", ItemRef: "TASK-1", ItemTitle: "Late", Reason: "due date was 2020-01-01"},
{Type: "plan_completion", ItemRef: "PLAN-1", ItemTitle: "Done plan"},
}

got := filterAgentAttention(attention)

var sawOverdue bool
for _, a := range got {
if a.Type == "overdue" && a.ItemRef == "TASK-1" {
sawOverdue = true
}
if a.Type == "plan_completion" {
t.Error("plan_completion is not an agent-actionable attention type and must be filtered out")
}
}
if !sawOverdue {
t.Error("`pad project stale` dropped the overdue entry; deadlines never reach the CLI surface")
}
}

// TestRemindArgsAcceptRearmWithoutARef — codex round 2.
//
// `--rearm` addresses a reminder by id and needs no item ref, but ExactArgs(1)
// forced one and the rearm branch then ignored it — so the flag could not be
// used at all, and the ref a user supplied to satisfy cobra was silently
// discarded.
//
// MUTANT: restore ExactArgs(1) and the zero-arg case fails; drop the
// ref-with-rearm refusal and the ambiguous case stops failing.
func TestRemindArgsAcceptRearmWithoutARef(t *testing.T) {
cmd := remindCmd()
if err := cmd.Args(cmd, []string{}); err != nil {
t.Errorf("remind must accept zero args so --rearm is usable: %v", err)
}
if err := cmd.Args(cmd, []string{"TASK-1"}); err != nil {
t.Errorf("remind must still accept an item ref: %v", err)
}
if err := cmd.Args(cmd, []string{"TASK-1", "TASK-2"}); err == nil {
t.Error("remind accepted two positional args")
}
}

// TestReminderCommandsExposeTheArgsMCPExpects — codex round 5, P1.
//
// cmdhelp derives positionals by regex from a command's `Use` string, and
// `<instant>` inside `remind <ref> --remind-at <instant>` matched: it became a
// second REQUIRED positional, so local stdio MCP dispatch failed with
// `missing required argument "instant"` — the action was advertised and
// unusable on that transport.
//
// The MCP catalog's own test did not catch it because its cmdhelp document is
// HAND-BUILT: I wrote `Args: mkArgs("ref")` there, so the fixture agreed with
// what I meant rather than with what the CLI says. This test reads the REAL
// tree, which is the only thing that can disagree with me.
//
// MUTANT: put a `<...>` placeholder back in any of these Use strings and the
// matching case fails.
func TestReminderCommandsExposeTheArgsMCPExpects(t *testing.T) {
doc := cmdhelp.Build(newRootCmd(), newRootCmd(), cmdhelp.Options{MaxDepth: -1})

for _, tc := range []struct {
path string
want []string
required []bool
}{
// `remind`'s ref is OPTIONAL: --rearm addresses a reminder by id and
// takes none. cmdhelp cannot express a conditional requirement, so
// declaring it required would be a machine-readable claim the command
// contradicts (codex round 6).
{"item remind", []string{"ref"}, []bool{false}},
{"item ack", []string{"reminder-id"}, []bool{true}},
{"item reminders", []string{"ref"}, []bool{true}},
{"item unremind", []string{"reminder-id"}, []bool{true}},
} {
cmd, ok := doc.Commands[tc.path]
if !ok {
t.Errorf("%q is missing from cmdhelp entirely", tc.path)
continue
}
var got []string
for _, a := range cmd.Args {
got = append(got, a.Name)
}
if len(got) != len(tc.want) {
t.Errorf("%q positionals = %v, want %v", tc.path, got, tc.want)
continue
}
for i := range got {
if got[i] != tc.want[i] {
t.Errorf("%q positionals = %v, want %v", tc.path, got, tc.want)
break
}
if cmd.Args[i].Required != tc.required[i] {
t.Errorf("%q arg %q required = %v, want %v", tc.path, got[i], cmd.Args[i].Required, tc.required[i])
}
}
}

// The flag MCP actually sends must exist under the name it sends.
if _, ok := doc.Commands["item remind"].Flags["remind-at"]; !ok {
t.Error("`item remind` has no --remind-at flag; the MCP remind_at param maps to nothing")
}
}
Loading