Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
14cb84c
test(schedule): cover acknowledged occurrences across public surfaces
xarmian Nov 13, 2026
34f428e
feat(store): acknowledge fired schedule occurrences without advancing…
xarmian Nov 13, 2026
60f74c9
feat(api): expose acknowledged occurrences and silence their alerts
xarmian Nov 13, 2026
82d204a
feat(cli): add occurrence acknowledgement and attribution output
xarmian Nov 13, 2026
809e3d2
feat(mcp): support acknowledged occurrence resolution
xarmian Nov 13, 2026
0e75d60
fix: move catalog references to 0.115 and give unknown agenda states …
xarmian Nov 13, 2026
93ef72c
feat(web): present acknowledged occurrences as open, owed work
xarmian Nov 13, 2026
6b573cc
feat(web): acknowledge a fired occurrence from the agenda
xarmian Nov 13, 2026
f74db61
fix(web): keep the separator before the acknowledgement attribution s…
xarmian Nov 13, 2026
4f2183d
fix(web): wrap the acknowledgement line in the Outstanding panel rath…
xarmian Nov 13, 2026
b1a1bcf
test: regenerate MCP wire goldens and assert the managed reminder ack…
xarmian Nov 13, 2026
465ca5a
fix(web): give the acknowledgement its own line on a phone, without a…
xarmian Nov 13, 2026
6f2d4b0
Merge branch 'rook/TASK-4536-web' into rook/TASK-4536
xarmian Nov 13, 2026
04dab10
fix: harden acknowledgement attribution and keep CLI agenda columns a…
xarmian Nov 13, 2026
1b48c14
fix: keep an acknowledgement through a past linked move and match pin…
xarmian Nov 13, 2026
f30846e
fix: canonicalize imported acknowledgement evidence before storing it
xarmian Nov 13, 2026
b9b694d
fix: never take the acknowledging account from an imported bundle
xarmian Nov 13, 2026
11addab
fix: read an acknowledgement journal row with no evidence as unattrib…
xarmian Nov 13, 2026
0ac5ac3
fix: refuse ack-only member case variants on the resolve route
xarmian Nov 13, 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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -402,7 +402,7 @@ directory for `claude-code`, and an `[mcp_servers.pad]` table in
project-scoped, it's install-on-request only — `--all` and `pad mcp status` cover
the per-user clients (including Codex) and skip it.

**Tool catalog (v0.114)** — eleven resource × action tools plus `pad_set_workspace`, and `pad_board` on remote HTTP with Apps enabled. Undeclared input keys are rejected with a structured error rather than silently dropped, and field values are typed against the collection schema on the server:
**Tool catalog (v0.115)** — eleven resource × action tools plus `pad_set_workspace`, and `pad_board` on remote HTTP with Apps enabled. Undeclared input keys are rejected with a structured error rather than silently dropped, and field values are typed against the collection schema on the server:

| Tool | Actions |
|---|---|
Expand All @@ -428,7 +428,7 @@ Plus resources at `pad://workspaces`, `pad://workspace/{ws}/dashboard`,
and `pad://_meta/version`.

**Stability contract.** The catalog is versioned
(`tool_surface_version: "0.114"`) and advertised in the initialize handshake and at
(`tool_surface_version: "0.115"`) and advertised in the initialize handshake and at
`pad://_meta/version`, so external agents can pin against it; the per-version
changelog lives in [`internal/mcp/version.go`](internal/mcp/version.go). Errors
come back as structured envelopes (`{error: {code, message, hint, ...}}`) from a
Expand Down
27 changes: 20 additions & 7 deletions cmd/pad/cmd_project.go
Original file line number Diff line number Diff line change
Expand Up @@ -240,12 +240,14 @@ func nextCmd() *cobra.Command {
// 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).
ReminderID string `json:"reminder_id,omitempty"`
ItemSlug string `json:"item_slug"`
ItemRef string `json:"item_ref,omitempty"`
ItemTitle string `json:"item_title"`
Collection string `json:"collection"`
Reason string `json:"reason"`
ReminderID string `json:"reminder_id,omitempty"`
ScheduleID *string `json:"schedule_id,omitempty"`
OccurrenceAt *string `json:"occurrence_at,omitempty"`
ItemSlug string `json:"item_slug"`
ItemRef string `json:"item_ref,omitempty"`
ItemTitle string `json:"item_title"`
Collection string `json:"collection"`
Reason string `json:"reason"`
} `json:"suggested_next"`
}

Expand All @@ -272,7 +274,9 @@ func nextCmd() *cobra.Command {
bold.Sprint(s.ItemTitle),
dim.Sprint(s.Reason),
)
if s.ReminderID != "" {
if s.ScheduleID != nil && s.OccurrenceAt != nil {
fmt.Printf(" %s\n", dim.Sprintf("acknowledge with: pad remind ack %s --occurrence %s", ackTarget(s.ItemRef, s.ItemSlug), *s.OccurrenceAt))
} else if s.ReminderID != "" {
fmt.Printf(" %s\n", dim.Sprintf("acknowledge with: pad item ack %s", s.ReminderID))
}
}
Expand Down Expand Up @@ -1521,3 +1525,12 @@ func watchCmd() *cobra.Command {
}

// --- agent roles ---

// ackTarget names an item for a `pad remind ack` hint: its issue ID, or its
// slug when the item has no number.
func ackTarget(ref, slug string) string {
if ref != "" {
return ref
}
return slug
}
115 changes: 84 additions & 31 deletions cmd/pad/cmd_schedule.go
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,7 @@ type resolveVerb struct {
}

var resolveVerbs = []resolveVerb{
{"ack", "ack", "Acknowledge the fired occurrence; keep its work outstanding"},
{"done", "done", "Mark the outstanding occurrence done"},
{"skip", "skip", "Skip the outstanding occurrence"},
{"snooze", "snooze", "Re-fire the outstanding occurrence later"},
Expand All @@ -287,6 +288,8 @@ func remindResolveCmd(v resolveVerb) *cobra.Command {
"Without --occurrence the CLI reads the schedule first and resolves the occurrence it reports.\n" +
"If that occurrence changed meanwhile (409 stale) the current one is printed."
switch v.state {
case "ack":
long += "\n\nAcknowledge a fired occurrence for everyone while keeping its work outstanding. Dates and recurrence do not advance; a configured lapse still applies."
case "done", "skip":
long += "\n\nA recurring schedule arms its next occurrence; a one-shot ends."
if v.state == "done" {
Expand All @@ -300,10 +303,14 @@ func remindResolveCmd(v resolveVerb) *cobra.Command {
if v.state == "move" {
long += "\n\nUse --role target|earliest|deadline|occupied_until with --to WHEN to preview a linked cascade. Repeat with --apply --revision TOKEN to commit; --reason records why, and --ack-violations accepts previewed bound crossings. This form refuses --until and --occurrence."
}
example := " pad remind " + v.use + " TASK-5" + map[string]string{"snooze": " --until 2h", "move": " --until 2026-11-10T09:00"}[v.state]
if v.state != "ack" {
example += ` -m "why"`
}
c := &cobra.Command{
Use: use,
Short: v.short,
Example: " pad remind " + v.use + " TASK-5" + map[string]string{"snooze": " --until 2h", "move": " --until 2026-11-10T09:00"}[v.state] + ` -m "why"`,
Example: example,
Long: long,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
Expand All @@ -318,7 +325,7 @@ func remindResolveCmd(v resolveVerb) *cobra.Command {
}
}
req := schedule.CommitmentResolve{State: v.state, Comment: comment}
if cmd.Flags().Changed("at") {
if v.state != "ack" && cmd.Flags().Changed("at") {
if v.state != "done" {
return fmt.Errorf("--at records an actual completion time and applies to done only, not %s", v.state)
}
Expand All @@ -344,9 +351,13 @@ func remindResolveCmd(v resolveVerb) *cobra.Command {
client, _ := getClient()
ws := getWorkspace()
if occurrence != "" {
if _, err := time.Parse(time.RFC3339, occurrence); err != nil {
parsed, err := time.Parse(time.RFC3339, occurrence)
if err != nil {
return fmt.Errorf("--occurrence %q: want an RFC3339 instant, as 'pad item remind show' reports it", occurrence)
}
if v.state == "ack" && (parsed.Nanosecond() != 0 || strings.ContainsAny(occurrence, ".,")) {
return fmt.Errorf("--occurrence must use whole seconds")
}
req.OccurrenceAt = occurrence
} else {
raw, err := client.GetItemCommitment(ws, args[0])
Expand Down Expand Up @@ -379,8 +390,10 @@ func remindResolveCmd(v resolveVerb) *cobra.Command {
c.Flags().StringVar(&to, "to", "", "linked move destination: RFC3339 or a wall time in your zone")
linked.addFlags(c)
}
c.Flags().StringVarP(&comment, "comment", "m", "", commentHelp)
c.Flags().StringVar(&at, "at", "", "when the work was really finished (done only): an RFC3339 instant or a wall time in your zone (2026-11-08T17:30)")
if v.state != "ack" {
c.Flags().StringVarP(&comment, "comment", "m", "", commentHelp)
c.Flags().StringVar(&at, "at", "", "when the work was really finished (done only): an RFC3339 instant or a wall time in your zone (2026-11-08T17:30)")
}
c.Flags().StringVar(&occurrence, "occurrence", "", "the occurrence's RFC3339 instant (default: the schedule's current one)")
if needsUntil {
c.Flags().StringVar(&until, "until", "", "when: an RFC3339 instant, a wall time in your zone (2026-11-10T09:00)"+map[bool]string{true: ", or a duration (2h, 1d)", false: ""}[v.state == "snooze"])
Expand All @@ -391,6 +404,8 @@ func remindResolveCmd(v resolveVerb) *cobra.Command {
func resolveSentence(state, ref string, req schedule.CommitmentResolve, s scheduleView) string {
occ := fmtInstant(req.OccurrenceAt, nil)
switch state {
case "ack":
return fmt.Sprintf("Acknowledged %s; this occurrence remains outstanding.", ref)
case "snooze":
return fmt.Sprintf("Snoozed %s until %s", ref, fmtInstant(req.Until, nil))
case "move":
Expand Down Expand Up @@ -424,12 +439,12 @@ func staleHintWith(err error, ref string, advise bool) error {
_ = json.Unmarshal(apiErr.Details, &d)
switch {
case d.NextOccurrence != nil && d.NextOccurrence.At != "" && !advise:
return fmt.Errorf("%s\ncurrent occurrence of %s: %s", apiErr.Message, ref, fmtInstant(d.NextOccurrence.At, nil))
return fmt.Errorf("%w\ncurrent occurrence of %s: %s", err, ref, fmtInstant(d.NextOccurrence.At, nil))
case d.NextOccurrence != nil && d.NextOccurrence.At != "":
return fmt.Errorf("%s\ncurrent occurrence of %s: %s (pass --occurrence %s to resolve it)",
apiErr.Message, ref, fmtInstant(d.NextOccurrence.At, nil), d.NextOccurrence.At)
return fmt.Errorf("%w\ncurrent occurrence of %s: %s (pass --occurrence %s to resolve it)",
err, ref, fmtInstant(d.NextOccurrence.At, nil), d.NextOccurrence.At)
case d.Ended:
return fmt.Errorf("%s\nthe schedule on %s has ended", apiErr.Message, ref)
return fmt.Errorf("%w\nthe schedule on %s has ended", err, ref)
}
return err
}
Expand Down Expand Up @@ -570,20 +585,22 @@ confirmation unless --yes is given.`,
// ---- agenda ----

type agendaEntry struct {
Forecast *models.ForecastSummary `json:"forecast"`
Type string `json:"type"`
At string `json:"at"`
Day string `json:"day"`
Date string `json:"date"`
AllDay bool `json:"all_day"`
State string `json:"state"`
ItemRef string `json:"item_ref"`
ItemTitle string `json:"item_title"`
Roles models.CommitmentRoles `json:"roles"`
Field string `json:"field"`
Overdue bool `json:"overdue"`
MissedCount int `json:"missed_count"`
Stale bool `json:"stale"`
Forecast *models.ForecastSummary `json:"forecast"`
Type string `json:"type"`
At string `json:"at"`
Day string `json:"day"`
Date string `json:"date"`
AllDay bool `json:"all_day"`
State string `json:"state"`
AcknowledgedAt *string `json:"acknowledged_at"`
AcknowledgedBy *models.AcknowledgedBy `json:"acknowledged_by"`
ItemRef string `json:"item_ref"`
ItemTitle string `json:"item_title"`
Roles models.CommitmentRoles `json:"roles"`
Field string `json:"field"`
Overdue bool `json:"overdue"`
MissedCount int `json:"missed_count"`
Stale bool `json:"stale"`
}

type agendaResponse struct {
Expand Down Expand Up @@ -733,12 +750,15 @@ occurrences unresolved for more than 14 days, oldest first. It takes no window;
}

type staleAgendaRow struct {
Ref string `json:"ref"`
Title string `json:"title"`
At string `json:"at"`
AllDay bool `json:"all_day"`
Roles models.CommitmentRoles `json:"roles"`
MissedCount int `json:"missed_count"`
State string `json:"state"`
AcknowledgedAt *string `json:"acknowledged_at"`
AcknowledgedBy *models.AcknowledgedBy `json:"acknowledged_by"`
Ref string `json:"ref"`
Title string `json:"title"`
At string `json:"at"`
AllDay bool `json:"all_day"`
Roles models.CommitmentRoles `json:"roles"`
MissedCount int `json:"missed_count"`
}

type staleAgendaAnswer struct {
Expand Down Expand Up @@ -811,7 +831,13 @@ func runStaleAgenda(cmd *cobra.Command, from, to string, daysSet bool, tz string
due = t.Format("2006-01-02") + " all day"
}
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%d\n", due, e.Ref, strings.Join(strings.Fields(e.Title), " "), schedule.CommitmentKind(e.Roles.Deadline != nil, e.Roles.OccupiedUntil != nil), e.MissedCount)
if e.State == "acknowledged" {
// The last column is not tab-terminated, so the attribution there
// does not widen or split the table's columns.
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%d (%s; %s)\n", due, e.Ref, strings.Join(strings.Fields(e.Title), " "), schedule.CommitmentKind(e.Roles.Deadline != nil, e.Roles.OccupiedUntil != nil), e.MissedCount, e.State, acknowledgementLine(e.AcknowledgedAt, e.AcknowledgedBy, showLoc))
} else {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%d\n", due, e.Ref, strings.Join(strings.Fields(e.Title), " "), schedule.CommitmentKind(e.Roles.Deadline != nil, e.Roles.OccupiedUntil != nil), e.MissedCount)
}
}
_ = w.Flush()
if resp.Truncated {
Expand Down Expand Up @@ -873,7 +899,13 @@ func renderAgenda(cmd *cobra.Command, resp agendaResponse, loc *time.Location, e
if e.Stale {
flags = append(flags, "stale")
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", day, clock, state, e.ItemRef, e.ItemTitle, kind, strings.Join(flags, ","))
flagText := strings.Join(flags, ",")
if e.State == "acknowledged" {
// In the last column, which is not tab-terminated, so it does
// not widen or split the table's columns.
flagText = strings.TrimPrefix(flagText+"; "+acknowledgementLine(e.AcknowledgedAt, e.AcknowledgedBy, loc), "; ")
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", day, clock, state, e.ItemRef, e.ItemTitle, kind, flagText)
}
_ = w.Flush()
}
Expand Down Expand Up @@ -908,3 +940,24 @@ func addRemindSubcommands(cmd *cobra.Command) {
}
cmd.AddCommand(remindLinkCmd(), remindGateCmd(), remindRenegotiateCmd(), remindAgendaCmd(), remindEditCmd(), remindRmCmd())
}

func acknowledgementLine(at *string, by *models.AcknowledgedBy, loc *time.Location) string {
name, agent := "unknown user", ""
if by != nil {
if by.Name != "" {
name = by.Name
}
if by.Actor == "agent" {
if by.AgentName != "" {
agent = " (" + by.AgentName + " via agent)"
} else {
agent = " (via agent)"
}
}
}
stamp := ""
if at != nil {
stamp = fmtInstant(*at, loc)
}
return "acknowledged by " + name + agent + " at " + stamp
}
4 changes: 2 additions & 2 deletions cmd/pad/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ func writeRootStructuredError(w io.Writer, err error) {
structured = true
case "invalid_body":
structured = cli.IsCommitmentRoute(commitmentErr.RequestPath)
case "hard_deadline", "deadline_owner_required", "commitment_changed",
case "not_fired", "stale", "schedule_error", "schedule_busy", "hard_deadline", "deadline_owner_required", "commitment_changed",
"schedule_linked", "deadline_approval_required", "link_violation_ack", "link_budget", "link_edit_required", "schedule_link_not_found",
"link_cycle", "link_self", "link_duplicate_incoming", "link_limit", "link_invalid", "gate_duplicate", "commitment_preview_required":
structured = true
Expand All @@ -101,7 +101,7 @@ func writeRootStructuredError(w io.Writer, err error) {
}
if json.Unmarshal(commitmentErr.Details, &detail) == nil && detail.Code != "" {
switch detail.Field {
case "reason", "revision", "role", "to", "cancel_policy", "offset", "parent_role", "child_role", "child", "blocker":
case "reason", "revision", "role", "to", "cancel_policy", "offset", "parent_role", "child_role", "child", "blocker", "state", "occurrence_at", "until", "actual_done_at", "comment":
structured = true
}
}
Expand Down
4 changes: 3 additions & 1 deletion cmd/pad/query.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,9 @@ Blocked items are never listed; 'pad project stale' shows them.`,
// 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 != "" {
if s.ScheduleID != nil && s.OccurrenceAt != nil {
fmt.Printf(" %s\n", dim.Sprintf("acknowledge with: pad remind ack %s --occurrence %s", ackTarget(s.ItemRef, s.ItemSlug), *s.OccurrenceAt))
} else if s.ReminderID != "" {
fmt.Printf(" %s\n", dim.Sprintf("acknowledge with: pad item ack %s", s.ReminderID))
}
}
Expand Down
Loading
Loading