Commit Graph

49 Commits

Author SHA1 Message Date
xarmian b437cc582d feat: item reminders — the fire-at-an-instant primitive, and one overdue rule for all four surfaces (IDEA-2641, closes #1010) (#1244)
* feat(store): item reminders — the fire-at-an-instant primitive (IDEA-2641)

Adds the storage, the scheduler tick, and the canonical event for one-shot
item reminders (GitHub #1010). Nothing in Pad acted at a target time before
this: a due_date makes an item show up as overdue once somebody asks the
dashboard, so "revisit TASK-X on the 1st" had to live in an external cron.

A TABLE, NOT A SCHEMA-FIELD ANNOTATION. The design sketch proposed marking
schema date fields with a `reminds: true` key on models.FieldDef; recon
overturned it. Such a key does not survive an ordinary collection edit, two
independent ways: the web editor destructures each field into an EditableField
and rebuilds a fresh definition key-by-key on save, so unknown keys are
dropped (`pattern` and `unique_scope` survive only because two lines were
hand-added for them), and models.CollectionSchema has fixed fields with no
catch-all, so any Go unmarshal+marshal round-trip strips unknown properties —
the hazard retargetRelationFieldsTx mutates raw JSON to avoid. Both failures
are silent and both disarm a whole collection's reminders at once. It is the
same defect class that moved traits out of the schema column in TASK-2657.

The table also gives the lifecycle a home. A reminder is armed, then fired,
then acknowledged, and a re-arm returns it to armed — per-reminder state a
field definition has nowhere to keep.

remind_at is an RFC3339 UTC instant, deliberately not a `date` schema value:
those admit both YYYY-MM-DD and full RFC3339 and are compared against the
SERVER'S LOCAL calendar day. A fire-at time cannot carry that ambiguity. The
remaining timezone question for due_date is filed separately.

Firing is one transaction per reminder carrying BOTH the fired_at write and
the outbox insert. That pairing is the point: a fired_at committed without its
event is a reminder that silently notifies nobody and can never be retried,
because the row has left the armed set; an event without fired_at fires every
tick forever. The UPDATE's own `fired_at IS NULL` predicate is the arbiter, so
two instances ticking at once produce exactly one winner.

item.reminder_due is admitted to the closed events/1 set as v1.2, with a new
PayloadReminder family and no SSE name. The subject is the REMINDER, not the
item: two reminders can be armed on one item, so an item-subject event could
not say which fired, and the reminder id is what an acknowledgement addresses.
A new payload family rather than reusing the item snapshot for the same
reason — a snapshot would validate and still not answer the only question the
event exists to answer. No SSE name in v1 because the poll surface is the
contract; adding one later is additive, removing one is not.

Ack is explicit and nothing else acks. An item reaching a terminal status
deliberately does NOT ack: that would make every status write a reminder
mutation, and it would silently consume a reminder set to fire after the work
was done.

* feat(server): reminder surfaces, and one shared overdue rule for all four

Second half of IDEA-2641: the HTTP surface, the scheduler tick's wiring, and
the fix for the finding that justified the unit — `ready` / `next` did no date
handling at all.

OVERDUE NOW HAS ONE IMPLEMENTATION. It used to live inline in the dashboard's
attention loop, which meant `pad project stale` inherited it (it filters that
very list) and the recommendation surface never saw it. So a deadline reached
the two surfaces that REPORT on work and never the one an agent PULLS from.
overdue.go is now the only place that decides, and all four call it.

Two behaviour changes fall out, both deliberate:

  - An overdue item bypasses the orphan branch's high/critical priority gate.
    That gate was where a deadline quietly stopped: a low-priority item three
    weeks late was reported by `stale` and never suggested by `next`.

  - Overdue sorts above in-progress. The list is capped at three, so a rank
    below in-progress would not merely order the deadline lower — on any
    workspace with three things in flight it would keep an overdue item off
    the surface entirely, which is indistinguishable from not shipping this.

The server-local-today comparison is UNCHANGED and known to be wrong for
multi-timezone deployments; it is filed as its own item with the cloud case
stated. Changing what "overdue" means on every existing instance inside a
change about where the rule LIVES is the kind of behaviour change nobody
reviews.

Fired reminders reach `next` / `ready` two ways, from one filtered list:
PendingReminders is the addressable form (it carries the id an ack needs), and
a prepended suggestion is the rendered form. They are prepended AFTER the cap
rather than entered as ranking candidates — a reminder is not a task competing
on priority, and whether it appeared should not depend on how busy the
workspace is.

Terminal-item reminders are FILTERED from the surface, never acked. Acking on
terminal status would couple every status write to reminder state and would
consume a reminder armed to fire after the work was done. The row stays
exactly as the user left it; the distinction is observable, and asserted.

Three guard tests caught this change and each was answered rather than
silenced:

  - The request-body reader guard was right: the handlers now go through
    decodeJSON, inheriting the NUL refusal and the size cap.

  - The canonical-events guard was right: item.reminder_due is admitted to the
    duplicated contract table as SPEC-3 v1.7, with the reminder subject kind
    and the new payload family. SPEC-3's own text owes the same amendment.

  - The NUL census asked for a decision on eight new columns. None carries
    caller text: ids and FKs are server-generated, four are the server clock,
    and remind_at is now re-parsed and re-formatted in the STORE as well as at
    the edge — so the stored value is always machine-produced from a parsed
    time and no caller bytes reach the column. The doc comment that used to
    say "the caller normalizes" protected nothing.

    Regenerating the baseline also found that GEN_NUL_BASELINE=1, which the
    test's own instructions name, was never implemented — the flag did
    nothing, so the documented path was hand-editing the file. Implemented, so
    the next reader gets the mechanism the instructions promise.

* test(reminders): the lifecycle, the four surfaces, and 22 killed mutants

Every test here was designed against a specific mutation and the mutation was
RUN. A green suite proves nothing about a suite nobody tried to break, and
three of the mutants I first wrote were not experiments at all.

Store (10 mutants, all killed): candidate predicate <= flipped to >=; the
event emission lifted out of the fire transaction; the fire UPDATE's
`fired_at IS NULL` arbiter removed; the RowsAffected check ignored; re-arm
clearing fired_at but not acked_at; ack losing `fired_at IS NOT NULL`; the
poll surface losing `acked_at IS NULL`; normalizeRemindAt no longer refusing;
it dropping .UTC(); GetReminder losing its workspace scope.

Surfaces (12, all killed): the priority gate no longer bypassing on overdue;
the sort no longer ranking overdue first; attention leaving the shared helper;
the reason losing its OVERDUE prefix; the comparison flipped to >; terminal
items no longer skipped; terminal reminders no longer filtered; the filter
ACKING instead of hiding; reminders appended instead of prepended; the tick
running on a far-future clock; ack answering 200 for an unfired reminder;
parseRemindAt accepting a bare date.

THREE MUTANTS DID NOT COUNT ON THE FIRST PASS and were rewritten. Two failed
to compile (`if false` orphaned a variable; deleting a parse orphaned an
import) and one had an anchor matching two call sites. A non-compiling mutant
emits zero FAIL lines and reads exactly like a surviving one — it invents a
hole that is not there — so the harness reports BUILD-FAIL and ANCHOR-BAD as
outcomes distinct from SURVIVED. It also restores files from an in-memory copy
rather than `git checkout`, which would delete uncommitted work in the tree.

ONE MUTANT GENUINELY SURVIVED and the test was at fault, not the mutant:
appending rather than prepending reminder suggestions was undetectable because
the fixture had a single item, so the reminder sat at index 0 either way. The
fixture now fills the three-item cap with in-progress work, where an appended
reminder lands fourth and vanishes. Faithful mutant, weak test — checked in
that order.

The same lesson shapes the four-surface fixture: it is a LOW-priority open
orphan, because that is the case the old code handled worst. A high-priority
task would have made the ready/next leg pass against the unfixed tree, which
is a green that measures nothing.

Negative controls throughout: a future deadline is not overdue and does not
reach the gate bypass; a tick with nothing due fires nothing; a completed item
is neither overdue nor suggested. Without them a helper that reported every
date, or a tick that fired everything, would satisfy every positive leg.

The lead's pin is asserted in both directions: a fired reminder on a done item
is ABSENT from the surface and PRESENT and still unacknowledged in the table.
Asserting only the absence would pass against an implementation that consumed
the row, which is the behaviour the pin exists to forbid.

* feat(mcp): pad_item.remind + ack-reminder, ToolSurfaceVersion 0.28

An agent that can RECEIVE a reminder but not set one has half the primitive.
The poll surface is pad_project.next / ready, both long exposed, so reminders
already reached agents — what was missing is the other half: deferring a piece
of work is exactly the moment an agent knows when it wants to be asked again,
and it had no way to say so.

Two additive actions, two optional params. Nothing existing moved, so a v0.27
consumer enumerating neither is unaffected — the v0.13 / v0.11 / v0.8
disposition, which likewise wired existing CLI verbs onto the catalog.

remind_at REFUSES a bare date rather than reading it as midnight. Worth
stating because the `date` schema type accepts YYYY-MM-DD and a caller will
reasonably try it here: a bare date names a 24-hour span, and choosing an hour
inside it would fire at a time nobody picked.

Re-arm and disarm stay CLI-only. Both address a reminder by an id the agent
would have to list first, and no listing action exists on this surface — a
door with no handle. Adding them later is additive.

Five guards had to be taught, and each was answered on its merits rather than
excluded: the HTTP parity test (route mappers added, so the actions work on
the remote transport rather than being advertised and unrouted), the
read-only catalog's cmdhelp fixture and expected cmdPath map, the field-
conflict classifier (remind_at / reminder_id are NOT field writers — a
reminder is a row in its own table addressed by its own id, so listing them
as classified sources would have pointed detectFieldConflicts at something
that is not a field source), and the instructions.md / README action tables.

That machinery is why the version bump is safe to make now, and it earned its
keep on this change: every one of the five failed on the first build after the
catalog entry landed.

CONVE-23 sweep for prose this falsifies:

  - SPEC-3 (DOC-2653) amended to v1.7 in the room, recording item.reminder_due
    with its new subject kind and payload family — the first canonical event
    with no user mutation behind it, since a scheduler tick produces it.
  - CLAUDE.md gains the reminder routes, the CLI verbs, and the v0.28 entry.
    It was also stale at 0.26 with NO v0.27 entry at all: the 0.27 unit swept
    instructions.md and README.md and missed this file. Both added.
  - skills/pad/SKILL.md gains the verbs and a routing entry, including the two
    things an agent will get wrong — the time is an instant, so ask for a time
    of day rather than picking one, and finishing the item does not
    acknowledge the reminder.

* fix(reminders): codex round 1 — four findings, all real, all with a pin

Round 1 found four defects and refuted none of them. Each fix carries a test
that fails against the code as it was, and each of those was mutation-checked.

**P1 — pending reminders bypassed item-level visibility.** Every other
dashboard section reads `allItems`, which the store already scoped to the
caller's collections AND their granted item ids. The pending-reminder list is
a direct workspace-wide query and inherited none of that, so a guest holding a
grant on ONE item could read the refs and titles of every other item in the
collection through its reminders — an item-level leak wearing a
notification's clothes. Now filtered with the same `isItemVisibleToGuest` call
the sibling sections use. The test's two items share a COLLECTION on purpose:
a collection-level filter was already applied, so separate collections would
have made it pass against the unfixed code.

**P1 — soft-deleted items could starve the queue permanently.** Candidate
selection ignored `deleted_at`, and `fireOneReminder` rolls back when it finds
the item gone — which leaves the reminder ARMED and therefore a candidate
again on the next pass. Candidates are ordered oldest-first and bounded by a
limit, so enough archived reminders fill every batch and no live reminder ever
fires. Silent, too: the tick reports zero fired and looks idle. Excluded in
the candidate query rather than skipped downstream, so those rows never occupy
a slot; the reminders themselves are kept, so restoring an item restores its
reminder with it — asserted, because a fix that reaped them would pass the
starvation test alone.

**P2 — the pass stopped at the first failing reminder.** The per-reminder
transaction exists precisely so one unfireable row cannot hold back the rest,
and `return fired, err` made that comment false — with candidates
oldest-first, one persistently broken old reminder blocks every newer one
forever. Now continues and joins the errors, so a pass that fired seven and
failed three reports both halves rather than reading as clean. The loop is
split behind an injected seam because a real mid-transaction failure is not
reachable from outside: the database refuses the corrupt rows that would cause
one (verified — invalid JSON in items.fields is rejected by the schema).

**P2 — suggestions dropped the reminder id.** The docs tell an agent to
acknowledge what it sees in next/ready, and the payload carried no handle: a
stateless poller could read the reminder and had no way to retire it, so it
would be shown the same item forever. `DashboardSuggestion` now carries
`reminder_id` (omitempty), `pad project next` prints the exact ack command,
and the test acks with the id the surface handed out rather than merely
checking the field is populated — a wrong-but-present id satisfies equality
with itself.

Four mutants, four killed; one was rewritten first because its anchor matched
two call sites and was therefore not an experiment.

* fix(reminders): codex round 2 — four findings, all real

**`--rearm` was unusable.** `ExactArgs(1)` forced an item ref that the rearm
branch then ignored, so the flag could not be reached without supplying a ref
that was silently discarded. Now `MaximumNArgs(1)`, with each mode checked
explicitly: a ref is required to arm, and a ref supplied ALONGSIDE `--rearm`
is refused rather than ignored — it names an item the reminder may not even
belong to, and quietly dropping it is how a user learns nothing about the
reminder they just moved.

**`unremind --format json` emitted plain text**, breaking the parseable-output
contract every sibling command honours.

**The MCP `ref` param did not list `remind`.** Agents read that flat
description to decide what to send, so an action missing from it is an
invalid call waiting to happen. It now also says what `ack-reminder` takes
instead, and why: a reminder is addressed by its own id because an item can
carry several.

**Fractional seconds fired early.** `time.Parse` accepts `09:00:00.900Z` and
`Format(RFC3339)` drops the fraction, so it was stored as `09:00:00Z` and
fired 900ms BEFORE the moment the caller named — silently, having rewritten
their value on the way in. Seconds are genuinely the stored resolution (the
column is compared as a string against a whole-second clock, and the tick runs
every 30s), so the only question was which way to resolve it, and truncation
resolved it the wrong way. `NormalizeInstant` now rounds UP: at most a second
of lateness, in exchange for a guarantee that can be stated — a reminder never
fires before the instant it was set for. Late is a reminder; early is a wrong
answer. Whole seconds round-trip exactly, which is asserted, because an
implementation that added a second unconditionally would otherwise pass.

Three mutants for this round, three killed (round-up→truncate,
round-up→unconditional-add, MaximumNArgs→ExactArgs). Thirty across the unit.

Two fixes carry no dedicated test and it is worth being explicit rather than
implying coverage: the `--format json` branch on `unremind` is a one-line
output change with no server-free way to drive it, and the MCP `ref`
description is prose the drift tests do not read — they assert an action is
DOCUMENTED, not that a param's sentence lists it.

* docs(reminders): the ack id is on the surface an agent polls, not only on the arm response

CONVE-23 follow-through on the round-1 fix. Both agent-facing docs told a
caller to acknowledge a reminder with the id "returned when you armed it" —
true, and useless to the caller that matters: a poller reading next/ready
never armed anything. The suggestion now carries reminder_id and `pad project
next` prints the exact ack command, so the docs say that instead.

The prose was written before the fix existed, which is exactly the case
CONVE-23 is about: a change that makes an instruction stale without touching
the file the instruction lives in.

* test(reminders): bind the tick LOOP to the work, not just the pass (CONVE-19)

Every other test in this file calls runReminderTick directly. That vouches for
the component and says nothing about whether anything ever calls it — a tick
that is never started is indistinguishable, from those tests, from one that
is. It is the convention's exact case, and the failure I recorded on my own
identity doc three times in one unit: I test the component and not the
binding.

Driven through the injectable tick channel so the assertion pins a SPECIFIC
pass instead of racing a 30-second ticker, and polled to a bounded deadline so
a loop that never runs FAILS rather than hanging the suite.

Mutant: drop `s.runReminderTick()` from the select and this goes red while
every direct-call test stays green. Killed.

The idempotence leg exists because a second Start spawning a second loop would
leave one running after Stop, making the BUG-842 drain invariant false for
this sweeper specifically — the one property a copied lifecycle is most likely
to get right by accident and least likely to be checked.

The cmd/pad call site (cmd_server.go, alongside StartTokenReaper) stays
verified by inspection: a source-scanning guard for it would be an instrument
asserting facts about source, which is code with an adversary and not worth it
for one line that sits in the middle of five identical neighbours.

* fix(reminders): codex round 3 — a deferred reminder fired anyway, and the poll surface was unbounded

**A re-arm mid-pass did not stop the fire.** The candidate scan selects an id;
before the UPDATE runs, a `--rearm` can move that reminder into the future.
Re-arm clears `fired_at`, so a predicate checking only `fired_at IS NULL`
still matched — the pass fired a reminder the user had just deferred and
emitted its event. The re-arm cannot undo that: it can clear the mark, but the
event is already on the outbox and at-least-once means a consumer has seen it.

The fire UPDATE now revalidates `remind_at <= nowTS` against the SAME nowTS the
candidate scan used. Same-value deliberately: the arbiter and the scan must
agree about when this pass is, or a reminder could pass one and fail the other
for no reason but clock drift inside a single pass.

**The poll surface was unbounded.** Every fired-and-unacknowledged reminder was
loaded and turned into a suggestion prepended to a list that is otherwise
capped at three, so a workspace with five hundred unacknowledged reminders
returned five hundred suggestions — in the dashboard response, the hottest read
in the product, growing until somebody acknowledged them.

Two bounds, because they are two different guarantees: the query takes a window
(default 50, oldest-fired first, so it holds what has waited longest), and the
prepended suggestions are capped at 5 so `suggested_next` stays a
recommendation rather than a second inbox. The full set stays addressable in
`pending_reminders`.

Truncation is REPORTED as a boolean, not a count. A count would have to be
post-visibility-filter to be true for the caller reading it, and the store
cannot compute that — the filter runs per item, above. "There are more than you
can see here" is the strongest claim the data supports, so it is the one made.

Four mutants; two killed outright, two survived and were run down under
CONVE-28:

- **Uncapped suggestions survived because the fixture had ONE reminder** —
  capped and uncapped are the same list at n=1. That is the SECOND time a
  single-item fixture hid a count-or-order property in this file. Fixture now
  arms eight; it also asserts all eight remain in `pending_reminders`, so the
  cap is pinned to the recommendation and not to the data.

- **Removing the SQL LIMIT survived, correctly, and the test comment now says
  so.** The Go slice cap bounds the PAYLOAD; the SQL LIMIT bounds the
  DATABASE'S work. Only the first is observable at this level — with the LIMIT
  gone the response is still bounded, while the query silently goes back to
  materialising every pending row before discarding most of them. That is a
  memory and I/O property with no assertion available here, so it is stated as
  a coverage boundary rather than papered over with a green that would not have
  measured it.

* docs(reminders): the fire predicate arbitrates against two actors, not one

CONVE-23 inside the file the round-3 fix touched. The comment described the
UPDATE as an arbiter for concurrent TICKS, which is what it was written for
and is why I did not re-read it when asked whether a user edit could race the
pass. It now says what it actually defends against, and names the general
shape: an arbiter is only an arbiter with respect to the writers it can see.

* fix(reminders): codex round 4 — the round-3 bound recreated the round-1 starvation

Round 3 bounded the poll surface. Round 4 caught what that bound did: the
query took the first N rows and the dashboard then discarded the ones it could
not show — hidden items, unauthorised items, completed items — so N such rows
hide a visible reminder behind them indefinitely, with no continuation to
reach it.

That is the SAME defect I had removed from the fire path one round earlier,
reintroduced in the read path within the hour. The general form is worth
stating because I clearly did not hold it: **a bounded window is only safe
when the discarding happens BEFORE the bound.** Filtering above a limit is a
starvation every time, and it does not matter what the filter is for.

Two halves, because the two filters are not the same kind of thing:

**Visibility is now scoped IN SQL**, using the same collection-id / item-id
sets every other dashboard section gets through `allItems` — the same
three-way shape as ItemListParams, where holding both collection grants and
item grants is an OR. Invisible rows no longer occupy the window at all, which
is strictly better than filtering them out afterwards and is what the sibling
sections have always done.

**Terminality is paged**, because SQL cannot evaluate it — a collection's
schema defines which statuses are terminal. The collector refills from the
next page when a page comes back short, bounded by a max scan so a workspace
full of completed items cannot turn a dashboard read into a table scan. The
bound is 10x the window: the common shape fills on the first page, and the
pathological shape terminates in a fixed number of indexed reads. Stopping at
the scan bound reports truncation, which is honest — there may be more, and we
did not look.

The empty-scope case is a THIRD state that reads like the second: nil
CollectionIDs means unrestricted, a non-nil EMPTY slice means this caller sees
no collections. Without an explicit guard they collapse, because the switch
matches none of its cases at length zero and adds no clause at all — so
"nothing visible" would return the whole workspace.

Three mutants, one survived: the empty-scope guard, because no dashboard-level
test produces that state (callers that would are refused earlier by workspace
access). Faithful mutant, missing test — it now has a direct one, with a
sanity leg so a build returning nothing cannot pass it by accident. A guard for
a state nothing exercises is exactly the one that rots.

* fix(reminders): codex round 5 — the MCP action I shipped did not work over stdio

**P1: local stdio MCP `remind` was unusable.** 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 dispatch failed with `missing required argument "instant"`. The
action was advertised on a transport where it could not run.

**The MCP catalog's own tests did not catch it, and the reason is the finding.**
That suite builds its cmdhelp document BY HAND: I wrote `Args: mkArgs("ref")`
in it, so the fixture agreed with what I meant rather than with what the CLI
says. Five parity and drift tests passed against a document I authored to
match my own intention — the "a test that agrees with whatever the table says
is not a test of the table" shape, which the canonical-events test warns about
in its own comment two packages away. The new test reads the REAL command tree
via cmdhelp.Build, which is the only thing in this repo that can disagree with
me about what the CLI declares.

**P2: `pad project ready` withheld the ack handle** that `next` prints.
Showing a fired reminder on the surface an agent polls while withholding the
id it needs to retire it means the same entry comes back on every poll,
forever.

**P2: suggestions asserted a collection they did not have.** The orphan branch
admits ANY collection — its own comment claimed it gated on tasks "mirroring
the active-plan branch", and that comment was simply false — while the output
hardcoded `Collection: "tasks"` and the reason said "Open task". Pre-existing
for high-priority items since BUG-1082; my overdue bypass widened it to any
overdue item, which is how it surfaced.

Fixed by carrying the item's REAL collection rather than by narrowing the
branch: narrowing would silently drop the non-task items this has surfaced for
a year, and the defect is the mislabelling, not the inclusion. The false
comment is replaced with what the code actually does.

The first version of that test used an overdue IDEA and SKIPPED — ideas use
`new`, and the branch requires `open` or an active status, so it never became
a candidate. A test that cannot fire is a failed reconstruction, not a pass;
the fixture is now a bug-like collection whose vocabulary contains `open`,
which is the population the defect can actually reach.

Three mutants, three killed. Forty-one across the unit.

* fix(reminders): codex round 6 — reminders fired from soft-deleted workspaces

**P1, and the only defect in this unit whose consequence leaves the process.**
Workspace soft-delete deliberately keeps items for the 30-day restore window,
so the candidate query's filter on the ITEM's deleted_at found nothing wrong —
and the tick kept firing, emitting outbound webhook events for a workspace
whose owner had deleted it, possibly while deleting their account.

Both queries now join workspaces and require `w.deleted_at IS NULL`. Nothing
is destroyed: a restored workspace resumes firing, which the test asserts,
because "stops firing" and "is destroyed" are very different answers to
someone who restores a workspace and only one of them is right.

That test first failed for the WRONG REASON and the fixture was at fault: it
counted every outbox row in the workspace, and item creation writes its own,
so the assertion was satisfiable by the fixture itself and discriminated
nothing. Scoped to the reminder event type.

**`Use: "remind <ref>"` declared a requirement the command contradicts.**
cmdhelp derives the machine-readable arg spec from that string, and `--rearm`
takes no ref — so the published contract said "required" for something
optional. The requirement is CONDITIONAL, which cmdhelp cannot express, so the
honest declaration is `[ref]` plus the explicit check that names both call
shapes. The round-5 test grew a `required` column, which is what makes this
observable at all: asserting only the arg NAMES would have passed.

**The pad_item tool description omitted both new actions.** The params were
declared and the actions dispatched, but the prose an agent reads to decide
what a tool can do did not mention them — discoverable only by someone who
already knew to look. It now describes both, including the two things an agent
gets wrong: remind_at is an instant, and nothing but an explicit ack retires a
fired reminder.

Three mutants, three killed. Forty-four across the unit.

* fix(reminders): codex round 7 — one predicate for the scan and the arbiter

Third instance of one class, so this fixes the SHAPE rather than the instance.

The class: the candidate scan filters on something the fire transaction does
not revalidate, so a change committed between them fires a reminder that no
longer qualifies. Round 3 was a re-armed instant. Round 1's soft-deleted item
was the same thing caught from the other side. Round 7 is a workspace deleted
between the scan and the fire — the round-6 fix added the condition to the
SCAN only, and the arbiter went on not knowing about it.

Fixing those one at a time is what let the third happen. `reminderFireable` is
now a single string that both sites reference: the scan asks it and the fire
UPDATE re-asks it, so they cannot disagree, and a fourth condition is one edit
in one place rather than two edits someone has to remember are paired.

Written as a correlated EXISTS on item_reminders.item_id rather than a JOIN
precisely so the identical text is valid in both a SELECT and an UPDATE, and
the scan drops its table alias so the two uses are the same characters.

What deliberately stays outside it: `fired_at IS NULL` and `remind_at <= ?`
live on the reminder row itself, are already spelled identically at both
sites, and folding them in would need a parameter order the shared form cannot
express. Said in the comment so the omission reads as a decision.

Both directions are now tested at the arbiter — a workspace deleted mid-pass
and an item deleted mid-pass — because the item case previously relied on the
item load coming back nil, and someone simplifying the EXISTS down to the
workspace check alone would otherwise still see green.

Three mutants, three killed: the arbiter dropping the shared predicate, and
the predicate dropping each of its two halves. Forty-seven across the unit.

* fix(reminders): codex round 8 — workspace export silently dropped every reminder

WorkspaceExport is a hand-maintained field list, so a new table joins it only
if someone remembers. Reminders did not: a backup/restore, or a
SQLite→Postgres migration via `pad db migrate-to-pg`, dropped every pending
reminder with nothing in the destination to show anything had gone.

The line that list has always drawn is item-scoped workspace CONTENT
(comments, links, versions — exported) versus per-user state (stars, watches —
not). A reminder has no user column and hangs off an item, which puts it on
the exported side. Stating the rule rather than just adding the field, because
the next person adding a table needs to know which side they are on.

LIFECYCLE MARKS ARE CARRIED, not reset. A fired-and-unacknowledged reminder is
still owed to whoever armed it, so it arrives pending; an armed one whose
instant has passed fires once on the destination's first tick, which is what
would have happened had the workspace never moved. Re-arming everything on
import would invent a schedule the user did not set. NULL rather than empty
string for the unset marks — the lifecycle is defined by NULL-ness, and ""
would make a never-fired reminder read as fired at "".

TestMigratedTablesCoversTheExport caught the second half, which I would have
missed: `pad db migrate-to-pg`'s NUL preflight decides what to REFUSE on from
MigratedTables, so a table the migration copies and the preflight does not
know about is a gap in exactly the guard that exists to prevent one. Added
there too, with the reason it can never actually fire — every column is
machine-produced, so it is listed for coverage rather than expectation — and
the "six tables" prose it falsified is now seven.

Two mutants, two killed: export dropping the block, and import discarding the
marks. Forty-nine across the unit.

* test(reminders): state the fire-path invariant and pin it from the invariant

The lead's read on why rounds 4 and 7 were the same class: the fire path had
no stated invariant, so each fix defended an instance. This states it, and
derives the pin from the paragraph rather than from the bug history.

THE INVARIANT: the candidate scan is a hint and may be assumed to prove
nothing. Every condition that made a row a candidate is re-asserted inside the
transaction that marks it fired, in the same statement that does the marking,
so checking and writing are one atomic act.

Worded as "the scan proves nothing" rather than as a list on purpose — a list
invites the next person to add a condition to the scan and stop, which is
exactly what happened four times here.

TestFirePathInvariant is the pin: one table, one row per scan-side condition,
each invalidating that condition in the window between the scan and the fire
and asserting the same three things — nothing fires, no event leaves, the
reminder is not consumed. The earlier per-defect tests are folded in as rows;
they said the same thing one instance at a time, which is how four of these
shipped. Adding a fifth condition to the scan without a row here should feel
like an omission. It carries a positive control, because four cases that all
assert nothing happens would pass against a build that never fires at all.

The matrix immediately falsified a claim in the paragraph I had just written.
I wrote that the item load inside the transaction is "for the payload, not for
the check"; removing the item half of reminderFireable alone changes no
observable behaviour, because the load then returns nil and the deferred
rollback undoes the write. Item liveness is defended TWICE and a single-mutant
experiment cannot say which guard is carrying it — removing both is what kills
the test. Both are kept, the predicate is named as primary (the row never
matches, so no write happens at all), and the asymmetry is stated: workspace
liveness has no second line, which is why dropping ITS half does fail the pin.

Six mutants: five singles plus the pair. Five killed alone; the item single
survives by design and is documented as such rather than left as an unexplained
green. Fifty-five across the unit.

* fix(reminders): codex round 9 — one legacy row could hide every reminder

**P1: items.item_number is NULLABLE and I scanned it into an int.** Migration
006 added the column to existing rows, so a pre-numbering item still carries
NULL — and scanning NULL into an int fails the Scan, which fails the QUERY,
which degrades the whole pending-reminder section. One old row, and the
feature is dark for everyone in that workspace.

ListWatchesForUser, which this query was modelled on, uses sql.NullInt64 for
exactly this column. I copied its shape and dropped the part that handles the
column's actual nullability — the same way of being wrong as the round-5
cmdhelp fixture: borrowing a form without borrowing what it knows. The legacy
row now carries no ref rather than a fabricated "PREFIX-0", which would name a
different item.

**P1: export shipped reminders that import could only discard.** The items
section filters on deleted_at IS NULL, so a soft-deleted item is not in the
bundle and its reminder can never be reunited with it. My comment claimed the
item_links rationale — round-trip the raw graph so a restore reunites them —
which is true for links and false here, because links keep soft-deleted
endpoints in the bundle and items do not. A link is a row ABOUT two items; a
reminder whose item is absent is a dangling schedule.

**P2: import wrote remind_at raw.** Import is a writer, and a bundle is not
necessarily one this server produced — hand-edited, or from another instance.
A local offset or a bare date would land in the one column every comparison
downstream treats as a UTC instant, firing early, late, or never. It now
normalizes like every other door. An unparseable value is SKIPPED with a
warning rather than failing the restore, matching the lenient import-side
precedent already in this file, and the raw value's LENGTH is logged rather
than its content.

Three mutants, three killed; two needed rewriting because the single-line form
did not compile — reverting the nullable scan also requires reverting the
render, and dropping the normalization orphans a variable.

PROCESS FAULT, recorded because it makes this round's findings weaker than
they look: I edited the tree while this review was reading it — committed the
invariant work and ran five mutation experiments, which write and restore
source, over the same files. A review binds to the tree it read and I moved it
underneath. Every finding above was re-verified against the current tree
before being acted on, and the next round runs with no concurrent edits.

* fix(reminders): codex round 10 — one orphaned item aborted a whole restore

An ORPHANED item — one whose collection is missing from the bundle — still
gets an itemMap entry. It has to: the entry is written before the skip because
parent resolution inside the same loop reads the map for items it has not
reached yet. So `itemMap[x] != ""` is satisfied by an id that names no row,
and inserting a foreign key to it fails (SQLite enforces FKs here via the
DSN's `_pragma=foreign_keys(on)`; Postgres always does).

The pre-existing mapping is the sharp edge. The aggravating half was mine:
this loop treated a failed reminder insert as FATAL, where item_links and
item_versions both skip, so one orphaned item carrying a reminder rolled back
an entire 900-item workspace restore. A reminder is the least critical thing
in a bundle and it had the strictest failure handling in the file.

Both halves fixed: the loop gates on items that actually landed, and a failed
insert warns and skips like its siblings.

TWO GUARDS THAT ONLY DIE TOGETHER, and this is measured rather than assumed.
Reverting either alone leaves the test green — with the map gate restored the
skip survives the FK failure, and with the fatal return restored the gate
means the insert never fails. Removing both is what fails it. They are kept as
a pair because they defend the same failure at different depths (prevent the
bad write / survive a bad write arriving some other way), and the pair is
recorded in the code so a future reader does not delete one as dead after
watching its mutant survive. Second time this shape appeared today; the first
was item liveness on the fire path.

The bundle in the test is hand-built, because ExportWorkspace cannot produce
an orphan — which is the reason it needed a test. That shape only arrives from
a hand-edited or foreign bundle, and surviving those is what import is for.

Three mutants: two singles that survive by design, plus the pair that kills.
Sixty-one across the unit.

* fix(reminders): codex round 11 — four contract slips, one of them another unit's

**suggested_next returned up to eight entries against a cap of three.** Round 3
prepended reminders PAST the list's own cap, reasoning they should not compete
for slots. Every consumer — the web dashboard, `pad project next`, `pad project
ready` — is written for three.

Worse, it silently falsified a decision recorded elsewhere: BootstrapDashboard
deliberately has no suggested_next_overflow_count BECAUSE this list is capped
at three upstream, and its comment names raising that cap as the moment to add
one. My change made another unit's reasoning wrong in a file I never opened.
The combined list is now trimmed back to three, reminders still leading — a
reminder can push a task suggestion out, which is the right way round, and the
full set stays addressable in pending_reminders.

My first version of that trim used `limit`, which is REASSIGNED above to
len(candidates) — so on a workspace whose only entries are reminders it would
have truncated to zero, killing precisely the case the surface exists for.
Caught by reading the surrounding lines before running anything; it has its own
test now.

**pending_reminders was uncapped in the bootstrap projection.**
BootstrapDashboard embeds *DashboardResponse, so every new field joins the boot
payload automatically — here, a window of up to 50, which is the budget
PLAN-1410 spent a unit trimming. Capped at 5 with an overflow count, under its
own constant rather than borrowing bootstrapAttentionCap: they answer different
questions and a future change to one must not silently move the other.

**Truncation was reported from the wrong question.** The collector used the
store's `more` flag, which answers "is there another PAGE", not "did I read all
of THIS one" — so a window filling part way through the final page reported
that the caller had seen everything while unread rows sat behind the fill
point. The paging bounds are now injectable so the case is testable at all:
building it with a window of 50 needs ~75 rows in a specific pattern, with a
window of 3 it is four.

**Import accepted acked-without-fired**, which is not one of the lifecycle's
three states. Such a row fires, is excluded from the pending surface because it
is already acked, and can never be acknowledged because AckReminder requires
acked_at IS NULL — an event emitted into permanent invisibility. The
acknowledgement is dropped and the schedule kept, since an ack of something
that never fired means nothing.

Five mutants, five killed (one rewritten — removing the flag orphans a
variable). Sixty-six across the unit.

* fix(reminders): codex round 12 — a read is not a hold; scope the arm; ack from the ack

Four P2s from round 12 (two independent runs, both landing on the same
line of the fire path), each closed at the layer where it lives:

- fireOneReminder pins the item and workspace rows FOR NO KEY UPDATE on
  Postgres before the arbiter UPDATE. reminderFireable re-asserted
  liveness at the predicate's instant and nothing held it to the commit
  instant; under READ COMMITTED an archival could commit in between and
  the event left the process about a deleted resource. Same idiom and
  same lock strength as CreateAttachmentForLiveItem; SQLite is excluded
  by its BEGIN IMMEDIATE, not skipped for convenience. Two PG-only pins
  verify "blocked" in pg_stat_activity, not by elapsed time; the
  pin-removed mutant fails both.
- CreateReminder asserts "live item of THIS workspace" in the INSERT's
  own SELECT and returns ErrReminderItemGone otherwise. The table had an
  FK and no same-workspace constraint; a mismatched pair fed another
  workspace's title to this one's dashboard and webhooks. Handler maps
  it to 404.
- AckReminder matches every fired row (COALESCE keeps the first ack,
  updated_at moves only when acked_at does), so a no-match means exactly
  "not fired at the instant of the ack". The handler no longer decides
  409-vs-200 from the row it read before the UPDATE.
- The invariant paragraph gains its missing sentence: "at that instant"
  means the commit instant, and the pin is what makes the predicate's
  instant and the commit instant the same one.

Round-12 caveat carried: both runs were static reads (sandbox blocked
Go's build cache), so "four" is a floor, not a measurement.

Refs IDEA-2641

* fix(reminders): codex round 13 — a reminder's workspace must agree with its item's, at every read

Every reader scoped by r.workspace_id and then joined the item without
asserting the two agree. No door writes a disagreeing row today
(CreateReminder derives the pair from the item; import maps within the
workspace), and the table has nothing that forbids one — so a hand-edited
bundle, a future move door, or a direct write would carry one
workspace's item into another's dashboard, export, and webhooks.

The identity goes into reminderFireable (scan + arbiter), the Postgres
row pin, ListPendingReminders and the export query. One test writes the
row raw — the only way one can exist — and asserts it is inert at each
site; the predicate-removed mutant scans and fires it.

Refs IDEA-2641

* fix(reminders): codex round 14 — the by-id and by-item reads assert the same identity as every other read

GetReminder scoped by the row's own workspace_id and ListRemindersForItem
by item_id alone, so a row whose two columns disagree — the class rounds
12 and 13 closed at the scan, the arbiter, the pin, the pending surface
and the export — was still readable through the two reads that reach a
single row. reminderOwned is that identity on its own, without the
liveness half those two reads must not have (a fired reminder on an
archived item is history worth showing). The write paths reach a row
only through GetReminder, so scoping it scopes them; a row no door can
write needs no door to delete it. ListRemindersForItem now takes the
workspace its caller already resolved the item in.

The raw-row test asserts both reads refuse the row from both sides; the
reminderOwned-removed mutant surfaces it through GetReminder.

Refs IDEA-2641

* fix(reminders): codex round 16 — an archived item's reminders are readable, and its verbs say "archived"

The doors resolved the item live. Listing an archived item's reminders
answered 409 from a GET, and ack/re-arm/delete answered a bare 404 for a
reminder that exists on an item that exists — while the store, since
round 14, deliberately keeps that history readable. The API already has a
posture for archived items: GET reads them, mutations answer 409
"archived … restore it before editing" (writeItemResolveError). The list
now follows handleGetItem; the lifecycle verbs load the item
include-deleted, run the visibility check first, and then answer the same
409 every other item mutation does. One test walks archive → list 200 /
ack 409 / arm 409 → restore → ack 200 on the same rows.

Refs IDEA-2641

* fix(reminders): codex round 17 — one suggestion per item, the archived 409 by slug, and the door courtesy named

Three findings on the server pass. (1) An item that was both a fired
reminder and an ordinary candidate appeared in suggested_next twice; the
ordinary entry is dropped, the reminder entry (which carries the ack id)
stays, and two reminders on one item remain two entries. (2) Round 16's
409 for an archived item's reminder was written by re-resolving item.Ref,
which is derived and empty for a legacy item with no item_number — so the
class most likely to be legacy fell through to a bare 404. The slug is
handed over instead. (3) The archived check in resolveReminderForWrite is
check-then-write, and an archive landing in between lets the verb through:
accepted and documented — it is the posture of every item mutation here
(UpdateItem's UPDATE has no liveness clause), the outcome is benign, and
putting liveness in AckReminder's WHERE would re-create the no-match
ambiguity round 12 removed.

Refs IDEA-2641
2026-09-04 11:36:11 -04:00
xarmian e747a1610c feat(session): registry keyed on the harness session, carrying the agent name; pad session list / prune (TASK-2767) (#1200)
## Summary

TASK-2767 (IDEA-2750 part 2, with part 3 riding along — the keying fix and the reaping are one mechanism).

The local session registry (`~/.pad/sessions`) was keyed on the pid of the `pad session register` subprocess, which is dead before anyone reads the file. One session left a new file per call and its own pid appeared in none of them; the only live identifier was the harness pid a reader could parse out of the socket path's basename. In practice nothing wrote it (zero callers in `plugin/`, `skills/`, or hooks) and nothing read it.

Now:

- **One record per session, keyed on the harness session pid** — `$PAD_SESSION_PID` (harness-agnostic override), else `$CLAUDE_PID` (verified present in both the tool shell and a live plugin monitor's `/proc/<pid>/environ`), else the calling process. A set-but-invalid value is an error, not a silent fall-through.
- **The record carries the agent name** the session's writes are attributed to (`ResolveAgentName`: `.pad.toml agent_name` → `$PAD_AGENT` → detected runtime; `--agent` overrides, `--agent ""` is anonymous), the harness session id, and the messaging socket's identity (inode/device/mtime — the same binding the arm-state file uses).
- **One owner-identity type, one verdict.** `internal/cli/session_owner.go`: `SessionOwner` + tri-state `OwnerLiveness` (`alive` / `dead` / `unknown`). `armStateOwnerAlive` is now `OwnerLiveness(...) == alive` with its file contract preserved (socket identity else mtime; headless pid + start token; fail closed). The registry pruner takes the opposite posture on `unknown`: on Windows `pidAlive` reports dead for every pid, and a reaper built on that would delete every live session's record.
- **Verbs:** `pad session register [--agent]` (writes/refreshes; prunes dead records), `pad session list [--agent] [--cwd] [--all] [--format json]` (liveness per row, newest first; dead hidden unless `--all`), `pad session prune [--older-than DUR]` (dead always; unknown only under an explicit bound; alive never). Nothing on MCP — host-local filesystem state.
- **Who registers:** `plugin/scripts/pad-monitor.sh` runs `pad session register` on start, BEFORE the consent gate — presence is a fact, consent is a grant, and the record is local/0600/never on the wire.
- **Legacy v1 files** list as `legacy` rows: owner = socket-basename pid (else registrar pid), liveness by pid only (v1 recorded no socket identity, and the socket-without-identity rule would have judged every legacy record dead while its session ran). A legacy row can say a session exists, never who it is.

Lead rulings on the four open decisions, all as built: `agent`/`--agent` vocabulary; no server-presence merge in `list`; register from the monitor script before the gate; wire follow-on (agent name on the stream) filed separately as IDEA-2750 part 2b.

One ordering change from the plan's section A: pid precedence is `PAD_SESSION_PID` > `CLAUDE_PID` > self (explicit override beats detection, mirroring `PAD_AGENT` over runtime detection); the plan listed `CLAUDE_PID` first.

## Behaviour changes for existing users of `~/.pad/sessions` / `pad session register`

- Registry files are keyed on the **harness session pid** (`PAD_SESSION_PID` → `CLAUDE_PID` → self), not the `pad` command's pid; repeated registrations overwrite one record instead of accumulating.
- `pad session register` records the agent name, harness session id and socket identity; stores the **real path** of the cwd; prints a different text line and a different JSON shape (the full `SessionRecord`); and **rejects** an invalid `PAD_SESSION_PID` / `CLAUDE_PID` instead of silently keying on itself.
- Existing v1 files are read as `legacy` rows (owner = socket-basename pid, no agent name) and dead ones are pruned by the next register.
- The plugin monitor now registers (and prunes) on every start, before the consent gate.
- `armStateOwnerAlive` now delegates to the shared `OwnerLiveness`; the consent gate's observable behaviour is unchanged on every platform and key type (codex round 4 traced every caller; matrix M29 pins the socket-keyed mapping).

https://claude.ai/code/session_016zc6oxBvpax6Z3iQMsAJno
2026-08-25 15:31:16 -04:00
xarmian 99ffad1bca feat(server): timeline comment rows carry the agent name (TASK-2760) (#1196)
* feat(server): carry the agent name onto comment rows in the timeline (TASK-2760)

An agent's comment rendered under the human's name: the name is stamped only
on the linked 'commented' activity, which the timeline suppresses because the
comment card stands in for it. The comment list queries now LEFT JOIN that
activity and surface the name as Comment.AgentName (top-level and nested
replies, on the timeline and the comments endpoint alike, through one scan
helper), mirrored onto comment-kind TimelineEntry.agent_name to match the
actor_name idiom. The web comment card renders it verbatim in an isolated
<bdi>, separate from the human author.

Store join rather than a handler-side match: the two lists are paginated
independently, so a handler join misses at page edges and reads as
intermittently-correct attribution. Metadata is parsed in Go, not SQL, to
keep the query free of a SQLite/Postgres dialect fork.

* test(store): make the activity-window premise strict, not a same-second coin flip (TASK-2760)

* fix(server): replies log + link their commented activity so the agent name reaches them (TASK-2760, codex r1)

The dedicated reply route wrote no 'commented' activity, and the activity is
the only row that carries the writing agent's name — so a reply through the
web UI rendered under a generic chip no matter what the client sent. Also
rewrites the README + SKILL.md claim that comments never show the name, moves
the reply test onto the real route, and asserts order/limit under the join.

* fix(store): exclude comment-linked activities in the timeline's activity query (TASK-2760, codex r2)

buildTimeline suppressed a comment's linked activity only when that comment
was on the same page; the two sources are paginated separately, so an
activity could slip through as a standalone 'commented' card. The query now
excludes linked rows via NOT EXISTS on idx_comments_activity (both dialects),
exact regardless of either window, and the page-local guard is removed
rather than kept as a dead one that reads as load-bearing.

* fix(store): item-scope the comment/activity link and freeze comment-linked activities against debounce merges (TASK-2760, codex r3)

The join keyed on activity id alone while nothing in the schema ties a
comment's activity to its item — scope both the LEFT JOIN and the NOT EXISTS
to the item. And CreateActivityDebounced could merge a later update into the
'updated' row a comment links to, overlaying its agent stamp and bumping
created_at, so two agents under one set of credentials would silently
re-attribute an earlier comment; comment-linked rows are no longer merge
targets. Prose corrected: the linked row is a 'commented' row OR the
'updated' row of an update that carried the comment.

* fix(server,web): keep the read-skew guard beside the SQL exclusion; nowrap on every 24ch agent label (TASK-2760, codex r4)

The page-local guard covers a distinct failure from the query exclusion —
a comment fetched then hard-deleted before the activity query runs — so it
returns with that reason written down. Sweep: of the seven 24ch agent-label
rules, three lacked white-space: nowrap (both timeline cards and
EpisodeFeed), so a name with spaces wrapped instead of ellipsizing; the
other four already had it. Prose nits corrected; the pre-link debounce race
on update-with-comment is recorded on BUG-2716 with a pointer in the handler.

* docs(server,cli): state the reverse read-skew at the guard and the CLI non-rendering decision (TASK-2760, codex r5)

* fix(store): debounce merge refuses a comment-linked row inside the UPDATE itself (TASK-2760, codex r6)

The read-then-write left a window in which a comment could link the chosen
row before the merge overwrote its agent stamp. The merge is now one
statement whose predicate re-checks the link under the row write, and a
zero-row merge falls through to a fresh insert. Prose corrected: a later
update looks past a frozen row, to an older unlinked one or a fresh one.

* fix(store,test): one freeze mechanism, and the window-edge leak proven end to end (TASK-2760, matrix survivors)

The debounce SELECT-side exclusion became redundant once the UPDATE's own
predicate refused linked rows, and its 'look past to an older unlinked row'
semantics folded a later change into an earlier entry — a linked row now
simply ends the coalescing run. And the server suite could no longer tell
the SQL exclusion from the restored in-memory guard, because it only
exercised the same-page case; a test now drives the page-edge case codex
found (comment outside its window, activity inside), where only the query
can help.

* fix(web): drop a duplicate nowrap in EpisodeFeed — the rule already had it (TASK-2760, codex r7)

Corrects the round-4 sweep count: of seven 24ch agent-label rules, two
lacked white-space: nowrap (both timeline cards), not three.
2026-08-24 18:09:02 -04:00
xarmian de3c9b818f feat(web): name the agent on the admin per-user activity views too (TASK-2759)
Codex round 12 — and it corrects MY exemption, not codex's reading of it.

I listed these two tabs as exempt because their local row type omitted
`metadata`. True, and the wrong reason: handleAdminGetUserActivity
serializes whole models.Activity rows, so the stamped name was on the wire
the entire time and only the client type dropped it. By this unit's own
discriminator — does the surface hold an Activity? — they were never exempt.
Verified against the handler before changing anything.

The consequence was the exact gap the audit log had, on the same rows: an
admin reading a user's activity saw "Updated an item via cli" with no way
to tell which agent acted. The lead ruled the audit log IN on this
discriminator; these belong in for the same reason.

Rendered with the same rules as every other surface — <bdi>, bounded at
24ch, title for the full value, nothing shown when no name was stamped.
Tests assert the binding at this surface (CONVE-19), including the empty
case, the non-agent case and the bidi one.

Docs updated: the surface list in the README and both SKILL.md copies now
names the admin console's audit AND per-user activity views. The precision
of that list is what round 2 was about, so it moves with the code.

Claude-Session: https://claude.ai/code/session_01Cpr3teiHHsgcTmg2xhHA86
2026-08-24 17:53:12 +00:00
xarmian fa22b6680e docs+test: correct two over-claims and pin name escaping (TASK-2759)
Codex round 2, fresh angles.

P1, accepted — my own docs over-claimed. The README and both SKILL.md
copies said the name appears wherever agent actors appear, including "item
timelines". Comments, version snapshots and note/decision entries carry the
actor KIND and no name (that is the exempt set the plan named, and TASK-2760
files the comment half), so on a timeline only ACTIVITY entries show it. Both
now say which entries carry it and which read "Agent".

P2, accepted — the README's fallback was wrong in a way that mattered. When
nothing resolves a name, the CLI omits X-Pad-Agent entirely (client.go:1884),
so actorFromRequest records the write as "user": it is attributed to the
PERSON, not to a generic "agent". Verified both call sites rather than
reasoning from the label. The generic "agent" rows that do exist come from
pre-naming writes and from audit events logged without agentMeta.

P2, accepted — the name is attacker-influenced text and every test used
benign values, so a rewrite to {@html} would have passed. Added a markup
payload at two surfaces that build their labels through different paths,
asserting no element is created and the text survives intact.

Claude-Session: https://claude.ai/code/session_01Cpr3teiHHsgcTmg2xhHA86
2026-08-24 16:45:35 +00:00
xarmian a3ba6eec6c docs: the "name your agents" story for agent attribution (TASK-2759)
The README's For AI Agents section promised that agent actions are
attributed, and said nothing about naming the agent — which was fair while
nothing rendered the name. Now that five surfaces do, the section carries
the precedence (.pad.toml agent_name -> $PAD_AGENT -> detected runtime),
where the name shows up, and that Pad renders it verbatim rather than
keeping a list of approved names.

The honesty framing is QUOTED from ResolveAgentName's own contract comment
rather than restated: the header is self-declared, an agent that omits it
is indistinguishable from the human whose credentials it uses, and a human
running `! pad ...` in an agent's terminal inherits that attribution. It
is a label an actor chose, not evidence about who acted — which is also why
the admin audit log shows both the agent and the account.

Both SKILL.md copies gain one clause: the name an agent sends is now
DISPLAYED, so a specific name beats a generic client id. Their existing
attribution principle was already accurate and is otherwise untouched.

Claude-Session: https://claude.ai/code/session_01Cpr3teiHHsgcTmg2xhHA86
2026-08-24 16:32:19 +00:00
xarmian 402f79e016 feat(store,server,web): collection kernel traits — de-hardcode conventions/playbooks slugs (TASK-2657, BUG-2702) (#1171)
Implements SPEC-5 §Collection traits (approved v1.1) — the first unit of
PLAN-2656 phase 0. Three kernel behaviors were keyed on the literal collection
slugs "conventions" and "playbooks": what the agent bootstrap loads, which
items route by invocation slug, and which items export as portable artifacts.
Collections now DECLARE those behaviors and the kernel resolves them from the
declarations.

Fixes the KERNEL half of BUG-2702, which stays open for the rest (see below).
A slug is not a stable identifier —
UpdateCollection re-slugs on any name change, and renaming a collection is a
documented onboarding step (TASK-1510) — so renaming either collection silently
detached all three behaviors from it, with the items still present and no error
anywhere. Measured on origin/main before the fix: conventions and
convention_index dropped 1 -> 0, playbooks 1 -> 0, and GET /playbooks/{slug}
went 200 -> 404, so `/pad ship` stopped resolving with no sign the playbook
still existed. Both halves are locked by regression tests observed failing on
unfixed code.

BUG-2702 is NOT fully closed here, deliberately. Every kernel behavior follows
the trait, and library activation on the MCP dispatcher and CLI was converted
too — but the pack's own dedicated web routes (/conventions, /playbooks list and
detail, /library) still address their collection by literal slug and render
empty after a rename. Filed as BUG-2705 with the route paths and the likely fix
shape; 2702 closes when that lands. Degradation there is bounded: no data loss,
and the collection stays usable at its own /[collection] route and in the
sidebar.

SPEC-5 was amended to v1.1 BEFORE any code, per the spec tree's own discipline:
bootstrap_include becomes a LIST of {mode, filter, key} because v1.0 could not
express convention_index at all; the conventions filter is now normative and
includes status=active, which v1.0's shorthand omitted and which the
implementation does enforce (implementing v1.0 literally would have leaked
draft conventions into every agent's boot payload); v1 filters are field-
equality maps with query/1 named as the widening path, since SPEC-2 is phase 1
and PLAN-2656 forbids growing toward it; and invocation_field is constrained to
the literal `invocation_slug`, because any other field name falls outside the
partial unique indexes in migrations/054 and pgmigrations/033 that are the real
uniqueness guard.

Traits get their own column rather than a key inside the schema JSON. The
schema column is overwritten wholesale on update and every client rebuilds it
fields-only, so a traits key stored there is destroyed by one ordinary
collection edit — measured during this task, not assumed. Trait authority
cannot rest on a value an unrelated UI save deletes. UpdateCollection writes
traits only when explicitly supplied, so pre-existing clients leave them alone;
an explicit "{}" still clears.

Bootstrap keeps its three payload keys as first-party views fed from the
declarations, and gains a generic bootstrap_includes array for any other
declared key — so the boot surface is genuinely generic rather than three
hardcoded payloads, and no consumer breaks.

Existing workspaces are backfilled slug-keyed in both dialects, guarded on
traits='{}' so a re-run cannot clobber a workspace's own declarations. The
backfill inherits today's blind spot (a workspace that renamed the collection
before upgrading is not reached) but cannot do worse than the status quo, which
is itself slug-keyed; from the backfill forward the hazard is structurally gone.

Malformed declarations are refused at create and update rather than stored:
an unparseable blob degrades to "declares nothing", which is silently the wrong
behavior instead of a loud error (SPEC-0 L6).

Web groups agent-facing collections by bootstrap_include presence, replacing a
hardcoded two-slug array repeated at five call sites.

Not done, deliberately: no MCP catalog change (traits are first-party kernel
declarations, no agent needs to set them, and the separate column means
pad_collection.update passes through harmlessly — no ToolSurfaceVersion bump);
bootstrap's collections[] projection does not carry traits (PLAN-1410 trimmed
that payload and nothing consumes them there); prefix.go's NormalizeSlug is
untouched (a pure function with no workspace context, and de-hardcoding two of
its six slugs would make it less coherent, not more).

Eight Codex review rounds found nineteen real defects, all fixed here. The
serious one:
bootstrap_include filter keys FAIL OPEN. The item store's field-filter path
drops any key its sanitizer rejects, removing the predicate rather than matching
nothing, so a declaration filtering on `"stat us"` would narrow nothing and ship
every convention — drafts included — to every agent at boot, defeating the
status=active guarantee this change makes normative. Filter keys are now
validated against the store's own sanitizer shape and pinned by a cross-package
agreement test, since models cannot import store and a future divergence would
silently reopen it. SPEC-5 amended to v1.2 with the rule and its fail-open /
fail-closed asymmetry. Also fixed: an unknown declared artifact_kind reached
artifact.Encode and surfaced as a 500 (now a 400 at the export boundary, since
SPEC-5 permits unknown kinds as legal non-round-tripping declarations); and
workspace import validated traits as JSON only, so an archive could persist a
declaration that degrades to "declares nothing" (now validated, degrading to
"{}" with a warning rather than refusing an import that may be the only copy).

Later rounds found more, and several were defects this change itself created.
A hidden collection could SHADOW a visible one: resolution used to name exactly
one collection, so with several declaring, resolving across all of them and
rejecting afterwards on visibility made a visible playbook unreachable behind a
hidden one — candidates are now filtered by visibility before selection, in both
playbook resolution and artifact import. Importing a pre-traits archive produced
an INERT workspace: the migration backfill cannot reach rows inserted long after
it ran, so conventions/playbooks arrived declaring nothing, and canonical
declarations are now inferred from the slug when a collection declares none
(never overriding declarations that survived the round trip). The generic
include path had no L4 boot budget and is now capped with an overflow count.
Trait parsing claimed to be strict but json.Decoder ignores trailing bytes, so
`{...} garbage` parsed cleanly. First-party payload keys are now mode-pinned,
since their projections have fixed shapes and declaring the other mode would be
silently ignored. Duplicate artifact_kind / invocation_field declarations are
refused at the collection API, and a conflicting archive warns on import.

Agent-facing text was updated with the rest, not after it: SKILL.md,
instructions.md and the MCP catalog said the literal slugs, which is exactly the
artifact an agent acts on. ToolSurfaceVersion 0.24 -> 0.25 for the
pad_library.activate behaviour change.

Trait uniqueness is a documented BEST-EFFORT gate, not an invariant, by lead
ruling. The gate reads then writes without a lock, import bypasses it, and a
rename can mint a duplicate without touching that path. The database-level
enforcement (partial unique indexes on the extracted traits) cannot ship first:
existing deployments can already hold duplicates via rename-then-reseed, so the
index would fail the migration on precisely the databases that most need
repairing. TASK-2710 carries the de-duplication pass and the indexes; SPEC-5
v1.3 records the deferral and the reason. L6's requirement that conflicts fail
loud is met by the refusal plus the warning — the mechanism is deferred, the
principle is not.

Gates: build · make lint 0 issues · go test ./internal/... · make test-pg ·
svelte-check 0 errors · vitest 99 files / 1734 tests. Mutation-verified across
four matrices, 20 mutations, 19 caught; the survivor is a seeding path whose
trait-vs-slug difference is unreachable today (SeedCollectionsFromTemplate
creates any missing template collection before it seeds items), recorded on the
task trail rather than papered over with a test that proves nothing.

Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V
2026-08-20 13:26:05 -04:00
xarmian 6f16003199 fix: surface implementation notes + decision log in the item timeline (BUG-2301) (#1144)
* fix(server): merge implementation notes + decision log into the item timeline (BUG-2301)

`pad item note` and `pad item decide` have written structured entries since
c61f4cda, and 998716ae deleted their renderer the next day as collateral of
the unified-timeline PR. The write paths kept working on CLI and MCP, so the
entries accumulated with no read surface outside `pad item show`.

Surface them as two more timeline kinds rather than rebuilding a separate
renderer: the endpoint already merges comments, activities and versions under
cursor pagination, and notes/decisions carry the same timestamp/actor/body
shape the merge handles.

They differ from the other three kinds in one way that matters. They are
elements of the item's fields blob, not rows, so they arrive whole on the
already-resolved item instead of through a cursor query. Without an explicit
filter they would therefore repeat on every page, so structuredTimelineEntries
applies the same (created_at, id) predicate the SQL sources use.

The blob is also hand-writable, which makes three shapes representable that a
table would not, all covered:

  - no created_at: anchored at the item's own creation instant, the earliest
    moment the entry could have existed. A zero-time fallback would render as
    1970 and sort below everything real.
  - no id: positional fallback, keeping the sort total and the cursor stable.
  - not an array at all: models.ExtractItem* already returns nil, so it
    contributes nothing. One live docapp item is in exactly this state
    (double-encoded JSON string) — filed as BUG-2627, a different defect.

Every guard here was mutation-verified: dropping the merge, neutering the
cursor predicate, and removing each of the two fallbacks in turn each fail
the tests that cover them. That pass also caught a vacuous assertion in the
actor test, which now counts the entries it asserts on (CONVE-12).

Frontend wiring follows in the next commit; the kinds are invisible until
ItemDetail's visibleKinds whitelist admits them.

* fix(web): render note + decision timeline entries and admit them to the tab filter (BUG-2301)

The server half is inert without this. `visibleKinds` is a WHITELIST with one
live call site, so a kind ItemDetail does not list renders on NEITHER tab — a
perfectly merged feed and an empty Activity tab, which is how this feature
shipped invisible the first time.

Two halves, both needed and both covered by mutation-verified tests:

  - ItemTimeline gains render branches for the `note` and `decision` kinds
    plus their rail dots. Without a branch the entry falls through the {#if}
    chain and draws an empty rail.
  - ItemDetail admits both to the Activity set. They belong there rather than
    with Versions: they record things that happened to the item, not restore
    points.

One TimelineStructuredCard serves both kinds. They share a shape — headline,
optional body, actor, timestamp — and differ in label, accent and weight, so a
variant keeps them from drifting the way two near-identical components would.
A decision carries the heavier treatment: it is the thing you go back looking
for.

Body text renders as plain text with `white-space: pre-wrap`, never through
the markdown pipeline, because that is what the writers produce — `pad item
note --details` and `--stdin` take raw text. A test pins that markup in an
entry stays inert.

The actor label reads the entry's self-declared `created_by`. That field lives
inside the item's fields blob and no server stamps it (BUG-2542), so the label
reports a claim, not a verified author; the comment in the card says so.

* docs(skill): document `pad item note` / `pad item decide` now that they have a read surface (BUG-2301)

The bug's own measurement found 185 notes and 33 decisions across seven
workspaces written by people and agents who found these commands on their
own — nothing in the skill, no convention, no playbook ever mentioned them.
That was defensible while the entries were invisible outside `pad item show`;
it is not once they render in the item timeline.

Flag names verified against the built binary's `--help` rather than the
source, since the skill is what an agent acts on.

* test(server): assert timeline paging is exactly-once, on both drivers (BUG-2301)

The single-page cursor assertions cover the predicate but not the property
that matters to a reader scrolling an item: every entry appears exactly once
across the whole feed. A too-loose predicate repeats the in-blob entries on
every page and a too-tight one drops them at a boundary, and neither is
visible from one page.

Run on Postgres as well as SQLite because there is a genuine seam here: the
structured entries are filtered in Go against a parsed time.Time while the
comment/activity/version sources are filtered in SQL against a formatted
string, and this endpoint has a Postgres-specific paging history (BUG-1086,
the \xff sentinel). Portability is asserted, not assumed.

The Postgres leg asserts the driver before doing anything, so it cannot pass
by silently re-running SQLite — verified both ways: it SKIPs without
PAD_TEST_POSTGRES_URL and PASSes with it. Mutation-verified too: neutering
the cursor predicate fails the leg on both drivers.

* fix(server): align the structured cursor with the SQL predicate and make blob ids unique (BUG-2301)

Three defects from Codex round 2, all in the cursor path this change added.

1. The "g" sentinel split the two kinds on their first letter. When a client
   sends `before` without `before_id` the handler substitutes "g" — an upper
   bound whose whole job is to KEEP same-second entries, and which does that
   only because every lowercase-hex UUID character sorts below it. Structured
   ids are not UUIDs: `note-…` sorts above "g" and `decision-…` below, so
   comparing against it literally dropped every note at the cursor instant
   while keeping every decision. The handler now says whether beforeID is
   synthetic, and the filter honours what the sentinel MEANS.

2. Two comparison spaces met on one page boundary. The SQL sources format the
   cursor to whole-second RFC3339 text and compare against a text column,
   while this filter compared full-precision time.Time. A structured entry can
   carry sub-second precision — a hand-written created_at, or the item's own
   createdAt standing in for an absent one — so the two predicates could
   resolve the same boundary differently and drop or repeat entries around it.
   Both sides now compare formatted whole-second text; the seam is removed
   rather than compensated for.

3. Duplicate ids were trusted. Nothing validates them on write, and a repeat
   is not cosmetic: it collides in the client's keyed {#each} (a hard render
   error), the client's loadMore dedupes by id and would drop the older entry,
   and the cursor cannot page past two entries it cannot tell apart. Repeats
   now take the same positional fallback an absent id takes, in one map shared
   across both kinds since they land in one merged stream.

Round 2's fourth item was a test gap rather than a defect, and is closed here
too: the paged walk asserted only that the three structured ids appeared once,
so a boundary mismatch that repeated a COMMENT or a VERSION would have passed.
It now asserts no entry of any kind repeats.

Round 1's only finding — structured entries do not live-refresh because the SSE
filter excludes item_updated — is DECLINED and recorded on the item. That
exclusion predates this diff and is deliberate (refreshing on every content
save caused visible shakiness and rate-limit errors); version entries already
carry the identical staleness, and these kinds have no web writer at all, so
no user acts and waits on one.

Each fix has its own negative control: removing the sentinel branch, reverting
to full-precision comparison, and trusting raw ids each fail exactly the test
that covers them.

* fix(server): truncate structured entry timestamps to the shared whole-second space (BUG-2301)

Codex round 3, P1 — and a correction to the previous commit, which fixed the
comparison and left the value itself alone. Filtering in formatted whole-second
text made the PREDICATE agree with SQL, but the entry still carried
full-precision time, so two paths stayed wrong:

  - the merge sorts on TimelineEntry.CreatedAt, so a fractional structured
    entry interleaved against same-second rows by a component those rows do
    not have, in an order the SQL ORDER BY cannot reproduce.
  - the client echoes the last entry's created_at back as the next page's
    `before`, where the store formats it down to the second. A cursor of
    10:00:00.5 becomes 10:00:00Z and EXCLUDES same-second rows that were still
    owed — silent data loss in comments and versions, sources this change
    never touched.

Truncating where the entry is built puts it in the same space as every other
source for all three purposes at once, which is what the fix should have been
the first time. Covered end to end: a fractional entry at a page boundary must
not cost a same-second row on the next page.

Round 3's P2 (a `has_more` heuristic that can stay true without pagination
progress when an over-fetched source is emptied by dedup) is NOT addressed
here. It is pre-existing — the heuristic and the discards it counts on both
predate this branch, and structured entries are never discarded by
buildTimeline, so this diff neither causes nor worsens it. I have not
reproduced it; recorded on the item for triage rather than asserted as real.

* fix: render payload-less structured entries, and make the fractional-boundary test actually discriminate (BUG-2301)

Codex round 4, all three findings.

The important one is against my own test. The fractional-timestamp regression
test walked two structured entries and no SQL-sourced row, so the data loss it
was named for could not occur in it — and confirmed by mutation: with the
truncation removed it still passed. Reworking it to include a real comment at
the note's own second was not enough either, and the reason is worth writing
down: the cursor's second term is the id, the SQL sources keep same-second rows
with `id < before_id`, and a realistic `note-<nanos>` id sorts ABOVE every
lowercase-hex UUID. The sibling row was rescued by the tie-break no matter what
the timestamp did. With an id below the UUID space the loss is reachable, and
the test now fails on the unfixed code by dropping the comment outright.

Two rounds of a correct-looking test that could not fail. The tell both times
was the same: I checked that the test passed with the fix and not that it
failed without it, on a fixture I had reasoned about rather than run.

Also:
  - A structured entry whose payload is missing now still renders its card.
    Guarding the branch on the payload left the rail dot and connector drawn
    beside nothing, which reads as a broken render rather than a thin entry;
    the card was already null-safe. Covered, and mutation-verified by
    restoring the guard.
  - Corrected a comment that claimed a zero-time fallback renders as 1970. Go's
    zero time is year 1, not the Unix epoch.

* docs(models): qualify the timeline paging claim to the static-dataset case (BUG-2301)

Codex round 5. The finding — the five sources are read at five instants with
no shared snapshot, so a concurrent note write can land between the item
resolve and the activity query and put one page briefly out of step — is real
but is NOT fixed here, deliberately:

  - It is the endpoint's existing shape, not something the structured kinds
    introduce. Comments, activities and versions were already three separate
    reads at three instants; this adds a fourth source, not a fourth class of
    problem.
  - Nothing is durably lost. The blob is authoritative and the very next fetch
    is consistent; the window is a request's worth of milliseconds on a
    read-only feed.
  - Every fix that would actually close it (a shared snapshot or a read
    transaction spanning all five sources) is a change to the endpoint's
    contract and the store's API, which is not something to do inside a bug
    fix for a missing renderer.

What IS wrong and is fixed: my own comment claimed paging "behaves identically
for all five" without qualification, and the earlier commit claimed exactly-once
paging flatly. Both are true over a stable dataset and neither said so. That is
the failure mode I keep hitting from the other side — being precise in the
artifact I am editing while an unqualified claim sits where the next maintainer
will actually read it. The type's doc comment now states the limit and says
whose problem it is.

* docs(web): record why the structured kinds inherit the timeline's SSE staleness (BUG-2301)

Codex raised the live-refresh gap twice and it was declined twice, which is
itself the signal that the reasoning belonged in the code rather than in a
review thread. The exclusion's comment now says what the two structured kinds
inherit from it and why admitting item_updated would be a bad trade.

* docs(server): name the cursor sentinel's UUID assumption at the sentinel (BUG-2301)

Lead's pre-merge ask, and the existing text was worse than merely silent: case
3 stated that the "g" sentinel keeps same-second entries, full stop. That is
true only for ids from the lowercase-hex UUID alphabet. Anything sorting above
"g" is dropped at the cursor instant instead, and a source whose ids straddle
it is split in half on their first character — which is exactly what happened
to `note-…` and `decision-…` here.

So the assumption is now named where someone adding a non-UUID id will read
it, rather than only in the helper that already works around it. An unqualified
claim at the point of use is the failure mode I keep meeting from both sides;
this is the same fix as qualifying the paging comment two commits ago.

Comments only — no behaviour change.
2026-08-17 12:58:09 -04:00
xarmian a963e68395 docs(skills): de-assume the slash-command surface + route onboard shortcut through the canonical playbook (BUG-2573/2574/2575) (#1139)
* docs(skills): de-assume the slash-command surface + route onboard shortcut through the canonical playbook (BUG-2573, BUG-2574, BUG-2575)

Three coherent drift fixes across the two skill trees:

BUG-2573 — skills/pad/SKILL.md is the embed source `pad agent install`
writes for Claude Code, Codex, Cursor, Windsurf, OpenCode, Amazon Q,
Junie AND pure-MCP agents, but three sentences presented the Claude Code
slash command as THE invocation ("There is one command: /pad <anything>",
"On every /pad invocation", "the first token after /pad"). Reframed per
the PLAN-1847 house pattern: natural language is canonical, typed forms
are per-surface shortcuts, and a read-/pad-as-shorthand rule covers the
rest of the document. Verified through the installed artifact, not just
the diff: built the binary and ran `pad agent install codex` — the
reframed text reaches the non-Claude skill verbatim.

BUG-2574 — plugin/skills/onboard/SKILL.md (the most direct onboarding
route a plugin user has) inlined its own post-link setup script, silently
opting that surface out of the workspace-owned, user-editable onboard
playbook — a customized playbook never fired via the shortcut, and the
inline copy covered roughly the build mode only. The post-link half now
loads and follows the playbook (with exact-title library activation —
`pad library activate "Onboard a workspace"`, verified against the CLI's
actual arg form) and routes needs_onboarding=false to the playbook's
revisit mode. The pre-link whoami-gated half stays as BUG-2541 left it.
Checked the other dedicated plugin skills for the same class: status and
capture inline nothing playbook-owned — no change needed.

BUG-2575 — plugin/skills/pad/SKILL.md didn't know specs are decomposable:
added the "break SPEC-1 into tasks" routing entry and the plan-or-spec
wording in the decompose workflow, matching decomposePlaybookBody and the
embed source. Also synced the one other surface-agnostic drift found in
the sweep: the convention_index note that a list without --full has no
content field.

Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V

* docs(skills): fix onboard-skill mode enum, needs_onboarding semantics, and reactivation path per Codex review (round 1)

Three corrections to the rewritten post-link half, all verified against
playbook_library_onboard.go: the mode enum is auto/build/audit/revisit
(auto default) with `defaults` a separate fast-path flag — the "four
modes incl. defaults" framing came from the tracking bug's own body and
was wrong; needs_onboarding:false only means a user-created item exists,
not that onboarding ever ran, so the skill no longer declares setup
complete on it; and a draft/deprecated onboard playbook must be
reactivated in place, since invocation_slug is workspace-unique and
library activation beside an existing entry duplicates or fails.

Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V

* docs(skills): activation before load + honest auto-mode routing per Codex review (round 2)

The post-link half now runs as an ordered three-step: ensure-active
(reactivate in place, library only when absent), THEN load the body,
then mode framing — a literal reader of the previous text ran
`pad playbook show onboard` before the existence check, failing on
missing playbooks and loading stale drafts. And the mode note no longer
claims auto picks "a fuller pass": verified against the playbook's
pre-flight, auto routes ANY user-created item to revisit, so the skill
now says to pass an explicit mode=build/audit override (which the
playbook honors) when the user says the workspace was never really set
up.

Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V

* docs(skills,mcp): propagate activation ordering + auto-mode routing to the sibling onboarding routes per Codex review (round 3)

The round-2 corrections lived only in the focused onboard skill; the
embed skill's Onboarding entry, the plugin pad skill's, and the
pad_onboard MCP prompt still said load-then-activate with a bare
library-activate fallback, and none warned that the playbook's auto mode
routes any workspace with user-created items to revisit. All three now
carry the same semantics: ensure-active first (reactivate a
draft/deprecated entry in place — invocation_slug is workspace-unique,
so library activation beside an existing entry duplicates or fails),
then load, plus the explicit mode=build/audit override note.

Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V
2026-08-17 09:51:38 -04:00
xarmian ac05d8a2b1 fix(cli): fail fast and quiet on non-interactive workspace init (BUG-2538, BUG-2577) (#1111)
* Fail fast and quiet on non-interactive `pad workspace init`

BUG-2538: initCmd drove runBrowserSetup/doBrowserLogin unconditionally
when the instance needed first-run setup or login, blocking a
non-interactive caller (script, CI, headless agent) on a browser
handoff nobody can complete. Gate both branches on
canPromptForConfig(), mirroring the precedent already used by
`pad init` (init.go:205-206), and fail fast with a hint pointing at
`pad init --email/--name/--password` or `pad auth setup`/`pad auth
login`.

BUG-2577: offerSkillInstall (shared by workspace init and workspace
link) printed a "(Y/n): " prompt even when the answer would be
auto-defaulted rather than read, because it gated on cli.IsTerminal()
(stdin only). Switch to canPromptForConfig() (stdin AND stdout),
which is the same predicate now used for BUG-2538 and the more robust
of the two checks already in the codebase.

Claude-Session: https://claude.ai/code/session_01BhQoeaWXxJbvw86ezzK8dt

* Fix wrong remedy in BUG-2538's !Authenticated error message

codex r1: the !Authenticated branch suggested `pad init
--email/--name/--password`, but those headless flags only bootstrap
the first admin account and only fire when SetupRequired — for an
already-set-up-but-unauthenticated instance, `pad init` falls through
to its own ungated Step 4 re-auth (BUG-2592), so the suggestion
relocated the hang instead of avoiding it. Drop the pad-init
suggestion in this branch only; point at `pad auth login` and note
there's no non-interactive login path yet. SetupRequired branch is
unchanged — its pad-init suggestion is correct for that state.

Claude-Session: https://claude.ai/code/session_01BhQoeaWXxJbvw86ezzK8dt

* Fix two more inaccurate remedies flagged by codex r2

1. SetupRequired branch: `pad init --email/--name/--password` silently
   eats the caller's workspace name/--template — pad init creates its
   own CWD-named workspace as a side effect, so a re-run of the
   original `pad workspace init <name> --template <t>` short-circuits
   on the link pad init just made with no signal <name>/<t> were
   ignored. Switch the remedy to `pad auth setup
   --email/--name/--password`, which bootstraps the admin account only
   (no workspace side effects), then re-run the original command.

2. !Authenticated branch: the "no non-interactive login path exists"
   claim was false — `pad auth login --interactive` reads
   email/password off a plain, TTY-ungated bufio.Reader
   (doInteractiveLogin, cmd_auth.go:554+; BUG-1886 made it
   piped-bytes-safe), so it works fine when credentials are piped in.
   Reworded to point at it and dropped the incorrect BUG-2592
   reference (that bug tracks pad init's ungated Step 4, not a missing
   login mechanism).

TestWorkspaceInitNonTTYSetupRequired's assertion updated from "pad
init" to "pad auth setup" to match; TestWorkspaceInitNonTTYNotAuthenticated
needed no change (still asserts "pad auth login").

Claude-Session: https://claude.ai/code/session_01BhQoeaWXxJbvw86ezzK8dt

* Update skill docs invalidated by the non-interactive fast-fail fix

codex r3: BUG-2538/BUG-2577 made this diff's own docs stale. Four
files (skills/pad/SKILL.md, plugin/skills/pad/SKILL.md,
plugin/skills/onboard/SKILL.md, plugin/skills/capture/SKILL.md) still
say non-interactive `pad workspace init` on a configured-but-
unauthenticated machine "blocks for minutes with no non-interactive
fallback" — that was true pre-fix (per BUG-2541's verification) and is
false now. Reworded the WHY without dropping the underlying
do-not-run-blind guidance: an agent's tool call is always
non-interactive, so it now gets a fast, actionable error instead of a
hang, but the error still just says a human needs an interactive
terminal — `pad auth whoami` remains the right check to run instead.
Where the docs' `pad init` claims are about the still-unfixed
session-expired path (BUG-2592, this diff's Step-4 sibling, left
untouched), those claims are unchanged and now cite BUG-2592
explicitly.

skills/INSTALL.md:24 updated separately (P3): notes the
non-interactive silent-install branch of `pad workspace init`'s skill
offer, alongside the existing interactive-prompt description.

Docs only, no Go changes — go build/test and embed.go's
//go:embed skills/pad/SKILL.md still resolve; no test asserts the old
wording.

Claude-Session: https://claude.ai/code/session_01BhQoeaWXxJbvw86ezzK8dt
2026-08-15 18:41:50 -04:00
xarmian 403a6de19d docs(skill): structured bootstrap-failure branch + onboarding precondition in the embed source (BUG-2541) (#1100)
* docs(skill): structured bootstrap-failure branch + onboarding precondition in the embed source (BUG-2541)

`skills/pad/SKILL.md` — the //go:embed source `pad agent install` writes into
user projects — had TASK-2537's minimal safety note but not the structured
branch the plugin copy carries. Ports it, minus the Claude-Code-specific shell
advice (the embed source also serves Codex, Cursor, Windsurf, OpenCode and
pure-MCP agents with no shell at all), so it now names the two stderr
signatures as separate cases with opposite handling.

Also adds the Onboarding routing entry's missing precondition. It sent the
agent to load the onboard playbook, which lives IN a workspace — so on the
unlinked path `pad playbook show onboard` fails exactly the way bootstrap just
did, and the entry routed into a dead end.

Ride-along from the lead's citation sweep: the stale "hangs indefinitely / no
timeout" wording still shipped in plugin/skills/onboard/SKILL.md and
plugin/skills/pad/SKILL.md (two places). All three now carry the bounded
wording.

RE-VERIFIED RATHER THAN INHERITED, per this item's own requirement — and the
inherited correction needed one too:

- Read the code myself. First-admin setup is capped at 20m
  (bootstrap.go::bootstrapPollTimeout). The auth poll
  (cmd_auth.go::pollAndSaveCLIAuth) has NO wall-clock limit of its own: it
  exits only on ctx.Done, `approved`, or `expired`, and `continue`s past
  transient errors. So the ~5m bound is entirely the server-side session TTL
  (cli_auth_sessions.go::cliAuthSessionTTL) — if the server becomes
  unreachable after the session is created, it polls forever. "Bounded, not
  indefinite" is right for the ordinary case and wrong for that one; the skill
  text now says both. Filed separately.

- Observed it, not just read it. On a configured-but-unauthenticated HOME,
  `pad workspace init` printed the browser URL and was still waiting when a
  25s cap killed it. `pad auth whoami` returned in 0.106s in that state AND in
  the unconfigured one, with distinguishable output — the "fast, safe" claim
  the whole branch rests on.

- The exact stderr strings were wrong in both copies: the not-configured case
  has NO `Error:` prefix (`Pad is not configured. Run 'pad auth configure'
  first.`), only the unlinked one does. Corrected from captured output.

Verified through `pad agent install claude` into a scratch project and read
back off the installed file, not the diff.

Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd

* docs(skill): `pad auth whoami` blocks in a TTY when unconfigured — qualify the claim (codex review)

Both plugin copies said `pad auth whoami` "never blocks waiting on input".
It does, in one state: `whoamiCmd` → `getConfiguredConfig()`, and on an
unconfigured machine that enters the interactive configure flow whenever
`canPromptForConfig()` is true — i.e. stdin AND stdout are both terminals
(configure.go:357). My own measurement (0.106s) was non-interactive, so it
could never have caught this; Codex found it by reading the call path.

The embed copy already had the right qualification ("in non-interactive use it
returns immediately"); this brings the two plugin copies in line and says why
the qualification is the operative one for an agent.

Codex's other finding — that the embed source's opening still frames `/pad` as
THE command, for surfaces with no slash commands — is real but pre-existing and
editorial rather than part of this port. Filed as BUG-2573. The auth-poll
timeout gap found while re-verifying the hang is BUG-2572.

Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd

* docs(skill): `pad init` does not fail fast either — correct both plugin copies (codex round 2)

Both plugin copies told agents that in the configured-but-unauthenticated
state, `pad init` "fails fast" and merely "needs a TTY to complete" — offered
as the contrast to `pad workspace init`'s browser-poll block.

It is false. `cmd/pad/init.go`'s Step 4 calls `doBrowserLogin` with no TTY
guard when the server is already initialized but the client isn't
authenticated. Observed: on a configured-but-unauthenticated HOME, non-
interactive, `pad init` printed the same browser URL and was still waiting
when a 20s cap killed it — identical to `pad workspace init`.

That parenthetical was the one thing in the paragraph that could have made an
agent run a command instead of handing back, so it was the worst line to have
wrong. Both copies now say it is no safer as a probe.

Third claim in these three files this task that was wrong because it was
reasoned rather than run — the first two being "hangs indefinitely" (bounded,
mostly) and "whoami never blocks" (it prompts in a TTY).

Codex's other two round-2 findings are real but out of this port's scope and
filed: BUG-2574 (the plugin onboard skill inlines its own script instead of
loading the canonical onboard playbook) and BUG-2575 (plugin decompose entries
omit SPEC targets the embed source and playbook both support).

Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd

* docs(skill): narrow the `pad init` claim to the state it is actually true in (codex round 3)

The previous commit replaced one over-broad claim with the opposite one. "Fails
fast" was wrong for the configured-but-unauthenticated case; "no safer as a
probe, blocks identically" is wrong for the genuinely-unconfigured one. Both
measured, non-TTY, same binary:

  unconfigured                  → 0.106s, "Error: Pad is not configured..."
  configured-but-unauthenticated → browser URL, still waiting at 20s

The instruction ("don't run it yourself") was right in both readings, so this
is the justification being wrong rather than the advice — which is exactly the
failure mode this whole item is about, and I reproduced it while fixing it.
Both plugin copies now say which state each behaviour belongs to.

Codex's other round-3 finding — that the plugin onboard skill gates its
recovery branch on `.pad.toml` being absent, so a STALE link skips it entirely
and dead-ends at the same bootstrap failure — is real and is the pre-link half
of the same skill's problem. Added to BUG-2574 rather than widened into this
port.

Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd
2026-08-15 08:36:56 -04:00
xarmian 212d59e7c6 fix(cli,server): make agent attribution actually happen (BUG-2542) (#1088)
* fix(cli,server): make agent attribution actually happen (BUG-2542)

Agent CLI writes were recorded as the human whose credentials they used.
Three independent defects, each verified by reading the path AND by
probing a live instance — the item deliberately held the mechanism open,
so none of this is inherited.

1. THE HEADER WAS NEVER SENT. actorFromRequest sets actor="agent" on one
   signal: the X-Pad-Agent header. The only code that sets it took the
   value from `agent_name` in .pad.toml and nowhere else — no
   environment detection, no session detection. This repo's .pad.toml
   has only `workspace`, so the header has never been sent from here and
   every agent write has looked human. ResolveAgentName now resolves
   .pad.toml → $PAD_AGENT → detected runtime.

2. ITEM CREATE DISCARDED THE ACTOR. createItemChecked called
   actorFromRequest and kept only the source (`_, src :=`), never
   setting input.CreatedBy, so store.CreateItem fell through to its
   "user" default — even for an agent that DID send the header.
   Comments have always stamped it correctly; item creation silently did
   not, which made the skill's own contract false on its own terms.

3. SINGLE-ITEM PATCH NEVER STAMPED LastModifiedBy. Bulk ops do
   (handlers_items_bulk.go); the single-item path did not, so an item
   edited only by agents read as human-edited.

Only entries VERIFIED against a live session belong in the runtime
detection table, so it has exactly one: Claude Code exports CLAUDECODE=1
to child processes, confirmed by reading a pad subprocess's environment
inside one. Guessing at Cursor/Windsurf/Aider variable names would put
unverified claims in a shipped binary and misattribute silently when
wrong; those set $PAD_AGENT until someone confirms a signature.

WHAT THIS DOES NOT DO, stated in the code and the skill rather than left
for someone to assume: the header is client-supplied and self-declared.
An agent that omits it is indistinguishable from the human it borrows
credentials from, and a human running `! pad ...` inside an agent's
terminal inherits that environment and is attributed to the agent. This
makes the trail HONEST, not VERIFIED — it is not a basis for
machine-verifiable human-approval provenance, which needs a channel the
agent cannot author at all. The incident behind this item is exactly
that distinction: an agent's relay of a human's words was recorded
indistinguishably from the human typing them.

Contract corrected in both skill copies, since the item's first question
was which of contract and behavior was wrong. It was the contract: it
promised automatic agent attribution that only ever applied to
workspaces that had opted in.

Tests, each mutation-tested against its own defect reverted alone:
- TestResolveAgentName — precedence plus the negative that makes it mean
  something: a plain human shell must still resolve to "". Fails 2/5
  reverted.
- TestItemAttribution_AgentVsHuman — agent and human legs for create,
  update and the create-stamp-survives-edit invariant. Fails on the
  create stamp reverted; fails 2/2 on the update stamp reverted.
  The update leg deliberately uses the OTHER writer: insertItemTx seeds
  last_modified_by FROM created_by, so a same-writer edit passes whether
  or not the PATCH stamps anything — the first version of this test did
  exactly that and passed its own counterfactual. Caught only because
  each fix was reverted separately.
- TestItemAttribution_ExplicitBodyValueWins — an explicit body value
  still beats the header.

End-to-end on a live instance through the real CLI, no .pad.toml opt-in:
agent session → created_by/last_modified_by/comment all `agent`; same
binary with CLAUDECODE stripped → all `user`.

Claude-Session: https://claude.ai/code/session_01QGbUKZBAZoWdEgiTNWsXag

* fix(server): artifact import wrote a UUID into created_by (BUG-2542)

Found by Codex while reviewing the attribution fix. handleImportArtifact
set `input.CreatedBy = u.ID`, which is the wrong DOMAIN for the field
rather than merely the wrong value: created_by holds the role — "user"
or "agent" — and consumers compare it against those literals
(CommentThread.svelte, TimelineVersionCard.svelte). An imported item
matched neither and rendered as neither.

It also would have defeated the fix in the parent commit at this path: a
non-empty CreatedBy suppresses the actor stamp, so imports would have
kept a UUID while every other create path started recording the actor.

The line contradicted the comment directly above it, which said Source
was being left blank precisely so createItemChecked could stamp it "like
every other create path". Now both fields are left blank and stamped
together.

The user's identity has its own home — the items.created_by_user_id
column — which no create path currently populates. That is a separate
gap and is not widened into this change.

Claude-Session: https://claude.ai/code/session_01QGbUKZBAZoWdEgiTNWsXag

* fix: close the remaining attribution bypasses Codex found (BUG-2542)

Review found no P1s and three P2 families beyond the artifact-import bug
already fixed in 6fac5dec. Two are closed here; two are deliberately not,
and the reasons matter more than the diff.

CLOSED — paths that asserted "user" and so SUPPRESSED the new stamp,
which made them worse after the parent commit rather than merely stale:

- cmd/pad/notes.go sent CreatedBy/LastModifiedBy = "user" from the
  CLIENT on all four note/decision writes. An explicit body value beats
  the header by design, so every agent note claimed a human wrote it,
  and would have kept claiming it. The client shouldn't assert an
  attribution it cannot know; all four now leave it to the server.
- handlers_item_versions.go hardcoded LastModifiedBy "user" / Source
  "web" on restore, so an agent-driven restore recorded itself as a
  human web edit. Now stamped from the request.

Also closed Codex's nit that the tests injected X-Pad-Agent directly and
never proved the resolver reaches the wire — TestClientSendsResolvedAgentHeader
runs the real client against an httptest server and asserts the header,
with a human-shell leg asserting its ABSENCE. Fails when the client wiring
is reverted. And the Source assertion now pins "web" rather than
merely non-empty.

NOT CLOSED, on purpose:

- Collab flush. An agent PATCH stamps `agent`, then the browser's later
  ?source=collab-snapshot PATCH stamps `user`. Codex reads that as lost
  attribution; I'm not convinced it's wrong — the browser really is the
  writer of that flush, and the agent's edit is already recorded on the
  PATCH that carried it. Deciding whose name belongs on a
  human-flushed doc containing agent edits is a semantics call about
  what last_modified_by MEANS, not a bug I should settle inside a fix
  commit. Filed rather than guessed.
- Move paths don't touch last_modified_by at all. That predates this
  change and is the same question (is a move an edit?), so it goes with
  the above.

Claude-Session: https://claude.ai/code/session_01QGbUKZBAZoWdEgiTNWsXag

* fix(cli): note/decision entries self-declare instead of going authorless

Self-caught regression from the previous commit, found by checking the
thing I changed rather than assuming it behaved like its neighbours.

I removed the CLI's hardcoded CreatedBy: "user" from note and decision
entries on the reasoning that applies to every OTHER write in that file:
an explicit value suppresses the server's stamp, so the client should
stay quiet and let the request context decide. That reasoning does not
reach these two. The entries live INSIDE the item's fields JSON, which
the server stores as an opaque blob and never parses for attribution —
so nothing downstream fills the gap, and blanking it would have written
authorless notes. Worse than the bug I was fixing: "user" was at least
right half the time.

They now carry cli.ActorKind() — the same self-declared signal as the
header, reduced to the user/agent enum the field holds. Its doc says
plainly that this is the ONE place a client should assert attribution,
and why, so the next person doesn't generalise it back the wrong way.

The item-level LastModifiedBy in the same functions stays server-stamped;
that half of the previous commit was right.

TestActorKind covers agent, explicit PAD_AGENT, and human-shell legs.

Claude-Session: https://claude.ai/code/session_01QGbUKZBAZoWdEgiTNWsXag

* fix(server): stamp the actor on non-parent item links (BUG-2542)

Last P2 from the review. Parent links pass the actor to SetParentLink;
every other link type (blocks / blocked-by / relates / implements) goes
through CreateItemLink, which the CLI calls without created_by, so the
store defaulted it to "user" and an agent's `pad item block` recorded a
human. Same one-line shape as the create path, explicit body value still
wins.

TestItemLinkAttribution_AgentVsHuman covers both writers; fails on the
agent leg when the stamp is reverted, control passes either way.

That closes every actor-dropping path the review found except the two
filed as IDEA-2549 (collab flush, move), which are semantics questions
about what last_modified_by means rather than defects — Codex agrees the
deferral holds if the field means content author, and flags that they
become real follow-ups if we decide it means sender-of-write.

Claude-Session: https://claude.ai/code/session_01QGbUKZBAZoWdEgiTNWsXag

* test(server): table-drive every non-parent link type (BUG-2542)

Codex nit: the link regression only covered `blocks`, and "shared routing
makes the other types fine" was doing the work. It cost nothing to stop
assuming, and the table earned itself on the first run — my initial list
included `blocked-by`, which is CLI surface sugar that inverts
source/target into a `blocks` row rather than a stored link type. The API
rejects it with a 400, on BOTH writer legs, which is also how that failure
reads differently from an attribution one.

Now covers blocks / related / implements / supersedes / split_from
against both writers.

One precision fix owed on 06938079's message: it says "THE HEADER WAS
NEVER SENT". Not true in general — a workspace with agent_name in
.pad.toml did send it, which is exactly how I probed the behaviour before
fixing it. Accurate version: the header was absent for anything that had
not opted in, which is every workspace I can see, including this repo's.
The body of that commit says it correctly; the headline overstates.

Claude-Session: https://claude.ai/code/session_01QGbUKZBAZoWdEgiTNWsXag

* style: gofmt notes.go after the attribution edit (BUG-2542)

Removing the hardcoded LastModifiedBy from the two ItemUpdate literals
left the surviving fields aligned to a column that no longer had a
member, so gofmt disagreed and CI's golangci-lint failed the Go job in
42s.

The real fault is upstream of the whitespace: my gates line for #1088
read "go test ./... green · Codex to CLEAN" and lint was simply not in
it. The omission in the report and the failure in CI are the same fact —
I reported a matrix that did not include the axis that broke. `make lint`
runs the pinned suite CI runs and takes seconds; it belongs in every
report I make, alongside test and build.

Claude-Session: https://claude.ai/code/session_01QGbUKZBAZoWdEgiTNWsXag
2026-08-13 16:21:10 -04:00
xarmian f241a6298e docs(skill): port the applicable plugin-review corrections into the embed source (TASK-2537) (#1087)
* docs(skill): port the applicable plugin-review corrections into the embed source

skills/pad/SKILL.md is the //go:embed source that `pad agent install`
writes into user projects; it had drifted from corrections made to the
plugin's copy during TASK-2534's review rounds. Selective port — the two
files diverge by design (surface-agnostic vs Claude-Code-only), so none
of the plugin's Claude-Code-specific material comes across.

Each ported item re-verified against the code, since line numbers and
wording differ between the copies:

- Ideation example passed `--content "..." --stdin` together.
  cmd_item.go:141 shows --stdin OVERWRITES the --content value and then
  blocks on io.ReadAll with nothing piped, so the example as written
  hangs. Dropped --stdin.
- Retro's plan-status flip carried no --comment, contradicting Key
  Principle 2 four lines below it.
- Key Principle 2 named `blocked` as a task status. templates.go:157 has
  open / in-progress / done / cancelled — no `blocked`.
- The role-board pointers claimed `pad server open` → /{workspace}/roles.
  cmd_server.go:999 appends only the workspace slug; there is no
  sub-path. Replaced with navigate-to-the-Roles-page wording.

Two more from the same review rounds that apply here and were not listed
on the task, found by diffing the copies:

- The convention/playbook BODY loads used `--format json` without
  `--full`. Since the v0.9 summary shape, `pad item list` returns
  cli.ToItemSummaries, which has no `content` field at all — so a skill
  told to "follow ALL returned conventions" was reading titles. Added
  --full to the seven trigger-load examples and to the retro's task
  load, where the bodies are the point.
- `open "$IMG"` is macOS-only, in a file installed on every platform.

Not ported, and not a gap in this file: the whoami-gated
`pad workspace init` routing. That correction guards a blind self-heal
in the plugin's bootstrap-failure branch, and the embed source has no
such branch — its only `pad workspace init` mention is a neutral
see-also. The absence of ANY bootstrap-failure guidance here may be
worth its own item; inventing that section is outside a port.

Verified through the real embed path, not by reading the diff: rebuilt
and ran `pad agent install` for both targets (claude → .claude/skills,
codex/cursor/windsurf/opencode → .agents/skills) into scratch dirs, and
confirmed the installed artifacts carry every correction and none of the
superseded strings. go test ./... green.

Claude-Session: https://claude.ai/code/session_01QGbUKZBAZoWdEgiTNWsXag

* docs(skill): --full on the eighth body-load; file the bootstrap-failure gap

Codex found one miss and one scope question.

The miss: the `convention_index` bullet's own body-load example lacked
both `--format json` and `--full`, so the call that the bullet exists to
describe — pull the triggered convention BODIES this index only names —
came back in the summary shape with no `content`. It is the eighth such
call; I corrected seven and missed the one inside prose rather than in a
code block. Added, with a clause saying why, since this is the bullet a
reader consults specifically to learn how to fetch bodies.

The scope question: the embed source has no bootstrap-failure branch at
all, so an unlinked workspace routes into onboarding that cannot run,
and a naive `pad workspace init` self-heal can hang the tool call
indefinitely. Codex is right that it is a real gap and right that it is
surface-agnostic. It is not a port, though — the plugin's correction
guards a self-heal this file does not contain, so there is nothing here
to correct, only something to write. Filed as BUG-2541 with the failure
modes, the verified hang, and the de-Claude-ing needed, rather than
widened into this PR.

Re-verified through `pad agent install`: 9 body-load calls now carry
--full in the installed artifact.

Claude-Session: https://claude.ai/code/session_01QGbUKZBAZoWdEgiTNWsXag

* docs(skill): warn off the blind `pad workspace init` self-heal

Codex held on the bootstrap-failure gap after I deferred it to BUG-2541,
and the objection is fair on one point: deferring the whole branch left
the shipped artifact one step from the hazard, since the documented
fallback ("use the individual CLI calls") needs the same workspace link
that just failed, so a reader following this file has nowhere to go and
`pad workspace init` is the obvious next reach.

So the hazard gets addressed here and the structure stays in BUG-2541.
Four sentences, surface-agnostic: bootstrap failing usually means setup;
the individual-call fallback won't help; do NOT run `pad workspace init`
blind, because on a configured-but-unauthenticated machine it hangs
indefinitely on a browser-setup URL with no timeout and no
non-interactive fallback — wedging the tool call rather than failing it;
gate on `pad auth whoami` and hand back to the user otherwise.

That IS what TASK-2537's finding-1 asked for — "the same whoami-gated
treatment" for this file's onboarding-adjacent text. I first read its
parenthetical ("if any references pad workspace init as a blind
self-heal") as gating the whole item, and since this file has no such
reference, as nothing to do. The intent was to keep the file from
leading an agent into the hang, which it could.

What stays in BUG-2541: the two stderr signatures as named cases, the
Onboarding routing entry's missing precondition, and re-verifying the
hang rather than inheriting the claim. Noted there.

Verified through `pad agent install` again, not the diff.

Claude-Session: https://claude.ai/code/session_01QGbUKZBAZoWdEgiTNWsXag

* docs(skill): narrow the workspace-init warning to what the code does

I wrote that `pad workspace init` "hangs indefinitely ... with no
timeout", inherited from TASK-2534's write-up. Codex flagged it and it
is wrong. What the code does:

- first-admin setup polls under an explicit 20-minute cap
  (internal/cli/bootstrap.go::bootstrapPollTimeout);
- the configured-but-unauthenticated branch polls a CLI auth session
  every 2s with no wall-clock timeout of its own, but exits on the
  server's `expired` status, and that session's TTL is 5 minutes (20 for
  the setup handoff) — internal/store/cli_auth_sessions.go.

Bounded, then. The hazard is still real and still worth the warning — it
blocks an agent's tool call for minutes on a flow only a human at a
browser can finish — but the text now says that instead of claiming a
permanent wedge. Also qualified the whoami claim: it returns immediately
in NON-INTERACTIVE use; getConfiguredConfig can prompt on an interactive
TTY (cmd/pad/configure.go:80).

Worth naming because it is the same failure I had just written into
BUG-2541 as work to do — "re-verify the hang claim rather than
inheriting it" — and then committed the inherited claim as fact in the
same breath. An explanation I have not checked is a claim, not a hedge,
and putting it in a file that ships to users makes it everyone's. The
correction is on BUG-2541 too, so that item's body isn't left asserting
it.

Claude-Session: https://claude.ai/code/session_01QGbUKZBAZoWdEgiTNWsXag
2026-08-13 12:43:22 -04:00
xarmian 7332a7f9f8 feat(collections): add spec workspace template — spec-driven development (IDEA-2527) (#1081)
* refactor(collections): extract tasksCollection/ideasCollection helpers

Pulls Tasks and Ideas out of Defaults() into standalone functions,
mirroring the existing docsCollection extraction. Seeded schema is
byte-identical; this just lets a template compose Tasks/Ideas without
also getting Plans, which the upcoming spec template (IDEA-2527) needs.

* docs(collections): generalize decompose playbook to plan-or-spec

The shared `decompose` library playbook was worded plan-only
(target description, pre-flight checks, body-analysis step). Broadens
the wording to also recognize a spec's `## Implementation plan` /
`## Acceptance criteria` sections as decomposition sources, ahead of
the spec template (IDEA-2527) reusing this playbook. Title is
unchanged (looked up by exact string elsewhere); wording is additive
so startup/scrum/product, which have no Specs collection, are
unaffected.

* feat(collections): add spec workspace template (IDEA-2527)

New "spec" template positions Pad as a spec-driven-development
platform: Specs (SPEC, draft→in-review→approved→implemented→
superseded, version+area fields, content_template skeleton) replaces
Plans as the parenting artifact — idea/bug → spec → tasks → PRs.
Deliberately no Plans collection; implementation-plan material lives
in the spec body's optional "## Implementation plan" section instead
(cheaper than maintaining two overlapping artifacts).

Ships:
  - SpecConventionTriggers/SpecPlaybookTriggers, extending the
    software trigger vocab with on-spec-draft/approve/change
  - Four seed conventions gating implementation, PR review, and
    spec-edit discipline on the spec lifecycle
  - Three full-prose playbooks: `/pad spec` (draft-first interview
    with IDEA/BUG graduation), `/pad verify` (walk acceptance
    criteria against the diff/behavior), `/pad extract-specs`
    (brownfield extraction with a subsystem-map human checkpoint and
    provenance-marked observed-behavior specs)
  - Reuses `decompose` (generalized in the previous commit) and
    `ship` (unchanged) as the remaining two seed playbooks

Registered in templates.go; adds the three new playbook bodies to
TestInvocationFramingStaysNLCanonical's scanned surfaces. Dedicated
tests in templates_sdd_test.go cover registration, Specs schema
shape, extended (not replaced) trigger vocab, the four conventions,
and the five playbooks.

Positioning/marketing page descoped from this PR — recon found no
marketing-page infrastructure in this repo to extend (the root route
redirects straight to /console); tracked separately.

* fix(collections): gate spec graduation, fix strict-parser arg contract

Codex round 1, findings 1-2:

- `/pad spec` accepted ANY ref matching the generic ref pattern
  (e.g. TASK-7) and would enter graduation mode, terminalizing an
  unrelated item's status. Dispatch now resolves the ref, checks its
  collection is actually Ideas-like or Bugs-like before graduating,
  and otherwise falls back to using it as recon context for a
  new-topic draft with no status flip.
- `target` was documented/declared optional, but the strict CLI/MCP
  parser only fills REQUIRED args positionally (internal/server/
  handlers_playbooks.go) — `pad playbook run spec "<topic>"` silently
  failed to bind it. Made `target` required, matching the `plan`
  playbook's `topic` precedent. `extract-specs`'s `target` stays
  optional by design (bare invocation is a supported flow); its
  Arguments docs now show the strict-path key=value form instead of
  implying positional works.

Adds a regression test (TestSpecPlaybookTargetArgumentRequirement)
pinning target's required-ness for both playbooks.

* docs: sync decompose's structured arg metadata + SKILL.md to plan-or-spec

Codex round 1, finding 3: the decompose playbook body was generalized
to plan-or-spec in an earlier commit, but its structured
`arguments` JSON metadata (the queryable contract) and
skills/pad/SKILL.md's Decomposition entry were left plan-only —
exactly the drift the body's own comment says these two surfaces
must not have.

* fix(collections): treat unedited Implementation-plan placeholder as absent

Codex round 1, finding 4: the spec content_template always ships a
populated "## Implementation plan" section, which meant decompose's
"no implementation plan -> fall back to acceptance criteria" path
never triggered for skeleton-created specs — it would treat the
placeholder's angle-bracket instruction text as a real task
candidate. The skeleton placeholder now tells the author to delete
the section if unused, and decompose's source-analysis step treats
an unedited placeholder the same as a missing section.

* docs(collections): drop phantom TASK-2528 reference

Codex round 1, finding 5: TASK-2528 doesn't exist in the docapp
workspace. The tasksCollection/ideasCollection extraction comments
now cite IDEA-2527 only.

* docs: add spec-target routing example to SKILL.md Planning section

Codex round 2, finding 1: the Planning section's decomposition routing
example only showed a plan target ("break plan 2 into tasks" → PLAN-2).
Adds a spec-target example alongside it, consistent with the
Decomposition entry further down (already generalized to plan-or-spec)
and the decompose playbook body/arguments.

* fix(collections): generalize ship playbook target to plan-or-spec

Codex round 3, P1: the spec template seeds ShipPlaybook() unchanged,
but its target contract documented only PLAN-ref | TASK-ref. Decompose's
step-7 report tells the user to run `/pad ship <target-ref>` on the
source ref, which in a spec workspace is SPEC-N — so the seeded
idea->spec->tasks->ship handoff broke at the last step.

Generalizes target's wording across all three surfaces (the Arguments
line, the argument-parsing PLAN-ref bullet, and the arguments-JSON
description) to PLAN-ref | SPEC-ref | TASK-ref, mirroring the same
additive pattern already used for decompose: a spec is a parenting
artifact with identical expansion mechanics to a plan (same
--parent-child wiring), so the change is inert for startup/scrum/product,
which have no Specs collection. No test pins the exact argument text,
so existing structural tests (TestStartupTemplateShipsShipPlaybook,
TestPlaybookLibrary_ShipBodyShared) pass unchanged.

* fix(collections): generalize ship's remaining plan-only mentions

Codex round 3 follow-up: two plan-only mentions left over from the
target-contract generalization.

- Commit-message template's "Parent: PLAN-XXX." -> "Parent: PLAN-XXX /
  SPEC-XXX.", plain aliasing matching everything else already
  generalized.
- Step 11's parent-closing guidance keeps the existing plan sentence
  as-is and adds the spec case as a judgment-trigger pointer rather
  than a parallel unconditional flip: a spec's terminal status is
  gated by verification (that's what the `verify` playbook is for),
  so ship tells the agent to run `/pad verify SPEC-XXX` instead of
  flipping the spec's status directly — it moves the spec to
  `implemented` only once the acceptance criteria actually hold.

* fix(collections): ship's PR-body guidance cites specs and their criteria

Codex round 4, P1: ship's PR-context generation still said "parent
plan" and templated the PR body under <PLAN-REF> only — so
`/pad ship SPEC-N` produced a PR that never cited the governing spec,
directly violating the spec template's own seeded on-pr-create
convention ("PRs cite the spec and which criteria they satisfy").

Generalizes the PR-body template to <PARENT-REF> (PLAN-ref or
SPEC-ref) and adds explicit guidance: when the parent is a spec, the
PR must also list which acceptance criteria it satisfies (e.g.
"Implements TASK-12 under SPEC-4, satisfies AC-1, AC-2") — this is
what makes /pad verify fast later, since the reviewer walks the cited
criteria instead of re-deriving intent. Also generalized step 1's
"check the parent plan's content" to plan-or-spec, since a spec
parent's acceptance criteria are exactly what step 8 needs to cite.

* fix(collections): verify gates the implemented flip on spec approval

Codex round 4, P1: /pad verify only excluded draft specs from the
flip-to-implemented, so it could promote an in-review or superseded
spec straight to implemented on the strength of passing acceptance
criteria alone — bypassing the workspace's own approval lifecycle.

Verification still runs and reports AC results regardless of status
(useful information either way), but the Resolve step's all-pass path
now branches on status: approved -> offer the flip (unchanged);
already implemented -> report the re-verify confirms it still holds,
nothing to flip; in-review -> report the pass but tell the user
approval isn't done yet, point at finishing review; superseded ->
report the pass but point at whatever spec replaced this one, since
that's the one that should be verified and implemented going forward.

* fix(collections): decompose treats placeholder ACs as absent too

Codex round 4, P2: the AC-fallback path treated unedited skeleton
placeholders (AC-1: <a statement...>) as real task candidates, so a
fresh untouched spec could decompose into bogus tasks. Extends the
same placeholder-as-absent rule already applied to the
Implementation-plan section: an AC-N line still holding the unedited
angle-bracket instruction text isn't a real criterion and doesn't get
a task proposed for it. If every AC-N is still a placeholder, there's
nothing to decompose from either source — decompose stops and tells
the user the spec has no real acceptance criteria yet.

* fix(collections): unify AC placeholder idiom, cover bare-ellipsis form

Codex round 5, P2: the seeded skeleton's AC-2 used a bare-ellipsis
placeholder ("AC-2: ...") while AC-1 used angle brackets and the
decompose placeholder rule only named the angle-bracket form — a
literal-minded agent could propose a bogus task for an untouched AC-2.

Two one-line fixes: the skeleton's AC-2 now uses the same
angle-bracket idiom as AC-1 ("AC-2: <the next verifiable criterion>"),
so the seeded skeleton has one placeholder style; decompose's
placeholder rule now also names bare ellipsis ("AC-N: ...") as a
placeholder form, as belt-and-suspenders for user-typed shorthand
beyond just the seeded skeleton.

No test pinned the AC-2 text, so no test changes needed.

* fix(collections): spec's circulate-for-review branch actually sets in-review

Codex round 6, P2: the "circulate for review" branch said to leave the
spec at in-review and stop, but the create command always uses
--status draft and no update followed it on that path — so the spec
silently stayed draft forever. Since round 4's fix gates verify's
implemented-flip on approval status, an item stuck at draft (never
even reaching in-review) is a stuck workflow, not just a label
mismatch.

Adds the explicit `pad item update <new-spec-ref> --status in-review
--comment ...` step to the circulate branch, parallel to the
approved-outright branch's existing update command, keeping the
audit-comment habit consistent with the rest of the body.

* fix(collections): add resume mode so circulate-for-review specs converge

Codex round 7, P1: the circulate branch stopped the playbook before
the graduation step, and nothing ever completed it — a later
`pad item update SPEC-N --status approved` was a bare status flip with
no agent step attached, so the source IDEA/BUG never got terminalized.
Worse, the advertised rerun path was broken: `/pad spec SPEC-N`
dispatched as non-graduation (a spec isn't Ideas/Bugs-like per the
round-1 gate) and would have created a SECOND spec instead of
resuming the first.

Three coordinated edits:

- New dispatch mode: a target resolving to the specs collection
  itself enters resume mode, never creates anything. Branches on the
  spec's status — in-review is the normal resume case (confirm
  approval, complete any pending graduation, offer decompose);
  draft/approved/implemented/superseded get the sensible remainder
  (offer the original choice again, report already-resolved state, or
  point at the successor spec).
- The circulate branch now records a "Graduation pending approval:
  <source-ref>" comment on the new spec when in graduation mode, so
  resume mode has something mechanical to find rather than relying on
  re-deriving intent from the Context section.
- The circulate branch's stop text now tells the user how the loop
  closes: rerun `/pad spec SPEC-N` when review is done.

Updated the `target` argument docs (body + arguments JSON) to name the
third accepted form. templates_sdd_test.go doesn't assert argument
description text, so no test changes needed.

* fix(collections): restructure graduation as one idempotent reconcile rule

Codex round 8, two P1s: the approved/implemented branch of Resume
never checked the pending-graduation marker (so a plain manual
`--status approved` never graduated the source), and the circulate
branch's marker write sat after "stop here" — a skippable step three
rounds of findings kept landing on. Scattering graduation state and
handling across branches was the actual bug; restructuring so the
class can't recur, not another branch-local patch.

- The graduation-link comment now gets written unconditionally at
  spec-creation time (step 5, graduation mode), before any
  approve/circulate branching — no ordering problem, no skippable
  step, exists on every path including a crash before either branch
  completes.
- One Reconcile rule, stated once in the Resume section: on every
  resume that reaches it (draft/superseded stop earlier and skip it;
  in-review-not-yet-approved also skips it), if the spec is now
  approved or implemented and its comments name a graduation source
  that's still open, complete the graduation — idempotent, so it's
  safe to run on every resume regardless of how approval happened
  (through this playbook, a crash-recovery rerun, or a bare manual
  status flip outside the playbook entirely).
- Step 6 (approve-outright path) is now just an invocation of
  Reconcile rather than parallel instructions — graduation mechanics
  described exactly once, referenced from both call sites.

Dispatch and Arguments text re-read coherent after the restructure;
no changes needed there beyond what round 7 already added.

* fix(collections): graduation idempotent by source, custom collection, promise wording

Codex round 9, five findings — the last substantive round before
remaining crash-window-shaped gaps become documented limitations
rather than more branches:

1. (High) Rerunning /pad spec IDEA-x mid-flight created a second
   spec — graduation wasn't idempotent by SOURCE, only by spec ref.
   Pre-flight step 2 (graduation mode) now checks, before drafting,
   whether the source's trail already shows a "Graduating into
   <spec-ref>" comment, or whether a search of the specs collection
   finds a spec whose Context names this source. Either match means
   this run is really a resume — switch to resume mode on the found
   spec instead of creating.
2. (High) A crash between create and the marker comment left an
   unlinked spec. Step 5 now writes markers on BOTH sides (source and
   new spec) immediately after create, back-to-back, shrinking the
   window. Documents the actual recovery mechanism instead of
   pretending atomicity: the skeleton's Context section always names
   the source, so finding 1's recon check catches even a marker-less
   spec on the next run.
3. (Medium) The resume-mode dispatch check tested only the literal
   "specs" collection, breaking for a custom `collection` argument.
   Now tests against the resolved `collection` argument (checking
   `pad collection list` if renamed), consistent with the existing
   ideas/bugs check.
4. (Medium) The opening promise ("nothing gets created until the
   user approves") contradicted the circulate path, which creates an
   in-review item. Reworded to match actual behavior: nothing is
   created until the user chooses approve-or-circulate; the draft is
   always presented in chat first.
5. (Note) The superseded branch skipped Reconcile unconditionally,
   stranding any pending graduation. Now checks the marker before
   stopping: if the source is still open, the pending graduation
   transfers to the successor spec (if findable) via the same marker
   comment, so the successor's own future Reconcile picks it up.

* fix(collections): make graduation's recovery claims actually true

Codex round 10, four sentence-scale edits closing the gap between
what the prose claimed and what it actually did:

1. (High) Pre-flight step 2's "recon check is the actual recovery"
   claim was false for a marker-less spec after a crash: the search
   found the spec, but Reconcile still needed marker comments that
   were never written, so it would no-op and strand the source. Now
   the discovery path repairs — writes both sides' markers right then
   if missing — before proceeding to resume, so the recovery claim
   holds by construction instead of by accident.
2. (High) Transferring a pending graduation to an already-approved-or-
   implemented successor (superseded branch) wrote the marker but
   never re-triggered anything to act on it. Now runs Reconcile on the
   successor immediately in that case (idempotent, source ref already
   in hand) instead of waiting on a rerun that might never come.
3. (Medium) "Skip straight to Resume below" bypassed Resume's own
   pre-flight (loading the spec's comments), which Reconcile depends
   on. Now explicit: run Resume's pre-flight first.
4. (Medium/borderline) "Never creates a second spec" overstated the
   guarantee for a SPEC-ref passed with a mismatched --collection.
   Softened to "never creates a duplicate within the resolved specs
   collection" everywhere the claim appears (Arguments prose, Dispatch,
   pre-flight, and the arguments JSON description) — consistent
   scoped truth in every location rather than a strong claim in one
   place and a weaker one elsewhere.

* fix(collections): enforce the Context-citation premise, walk the chain

Codex round 11, two findings:

1. (High) The whole crash-recovery mechanism (pre-flight step 2's
   search, step 5's recovery claim) depends on a graduated spec's
   Context section naming its source — but nothing enforced that; the
   skeleton's own Context hint says "(if any)" since most specs
   aren't graduated, and step 3 never mandated the citation for the
   ones that are. Added the explicit rule to step 3: in graduation
   mode, Context MUST cite the source ref by ID (e.g. "Grew from
   IDEA-12"), stated with the reason — it's the search key crash
   recovery depends on. Reinforced in step 5's and pre-flight step
   2's claim text: attributed the guarantee to the playbook's own
   mandate, not to "the skeleton," which doesn't itself enforce
   anything.
2. (Medium) Transferring a pending graduation to a successor that is
   itself superseded parked the marker somewhere no rerun would ever
   look — chains of supersession weren't walked. The superseded
   branch now walks to the LIVE HEAD of the chain (bounded, ~10 hops)
   before transferring or reconciling; a loop or dead-end mid-chain is
   treated the same as no successor found, rather than guessed at or
   walked forever.

* fix(collections): don't silently pick a branch in the supersession chain

Codex round 12, the last: the chain-walk from round 11 silently picked
one live head when supersession branches (more than one spec claims to
supersede the same spec). One clause, grouped with the existing
loop/dead-end stop rule: if more than one spec claims to supersede the
same spec at any point in the walk, stop and ask the user which is
canonical before transferring — don't pick silently.
2026-08-12 14:20:02 -04:00
xarmian 4bacea530f feat(mcp): expose pad_project ready + stale actions (#878)
Add read-only `ready` and `stale` actions to the pad_project MCP tool,
mirroring the existing CLI `pad project ready` / `pad project stale`.
`ready` returns the actionable backlog (query-oriented counterpart to
`next`); `stale` lists items needing attention. Both HTTP dispatchers
already existed; this wires them onto the catalog surface.

`pad project reconcile` stays CLI-only (shells out to `gh` for live PR
state — a local-git dependency MCP agents lack).

Bumps ToolSurfaceVersion 0.12 -> 0.13 across version.go, instructions.md,
README, CLAUDE.md; adds readOnlyActions entries, drift-guard test entries,
and a SKILL.md routing line.

TASK-2019

Claude-Session: https://claude.ai/code/session_019knGmnHcx5rrgWXQ8V8DZS
2026-07-08 16:36:06 -04:00
xarmian c846cff4fd feat(project): agent-accessible activity feed (pad project activity + MCP action) (#877)
* feat(project): agent-accessible activity feed (pad project activity + MCP action)

Add a non-streaming, bounded activity query so agents can catch up on
what other agents/users changed since they last worked — the query
counterpart to the live `pad project watch` SSE stream.

- CLI: `pad project activity [--limit N] [--actor user|agent] [--since DATE]`
  backed by the existing GET /workspaces/{ws}/activity feed.
- MCP: `pad_project.activity` action (passThrough) + cloud HTTP route.
- Extend the activity endpoint with a server-side `since` date filter
  (handler parse + store SQL clause) so limit/actor/since behave
  identically across CLI, stdio MCP, and cloud HTTP transports.
- Bump ToolSurfaceVersion 0.11 -> 0.12 (drift guard, README, CLAUDE.md,
  instructions.md) and add a SKILL.md querying-guidance line.

Tests: store since-filter test, HTTP dispatch test, catalog action test.

Claude-Session: https://claude.ai/code/session_019knGmnHcx5rrgWXQ8V8DZS

* fix(mcp): mark pad_project.activity read-only in tool surface

Add activity to readOnlyActions so the serialized MCP tool surface emits
read_only:true (missing entries default to write). Spot-check it in
tool_surface_test.go to guard against regression.

Claude-Session: https://claude.ai/code/session_019knGmnHcx5rrgWXQ8V8DZS
2026-07-08 16:26:41 -04:00
xarmian 7aa5cb98f3 perf(bootstrap): compact JSON for agents; trim SKILL.md reference sections (#873)
Part A: `pad bootstrap --format json` now emits compact (no-indent) JSON
via a new cli.PrintJSONCompact helper. Its canonical consumer is the /pad
agent skill; pretty-print indentation was ~29% of the payload (49696 -> 35118
bytes on this workspace, saving 14578 bytes). Humans keep --format markdown.

Part B (conservative): condense the Role Awareness section and the
playbook-authoring guidance in skills/pad/SKILL.md to on-demand pointers,
keeping the load-bearing core behavior + activation gotcha inline and ALL
routing behavior intact. Saves 2764 bytes of fixed per-session overhead.

No MCP tool-surface change; ToolSurfaceVersion unchanged.

Claude-Session: https://claude.ai/code/session_019knGmnHcx5rrgWXQ8V8DZS
2026-07-08 15:08:13 -04:00
xarmian 15ad930d78 feat(bootstrap): add convention_index for triggered-convention discovery (TASK-2004) (#848) 2026-07-07 17:28:33 -04:00
xarmian 7ec19b5f7f feat(skill): NL-canonical playbook invocation in SKILL.md + MCP catalog (TASK-1859) (#749)
* feat(skill): NL-canonical playbook invocation in SKILL.md + MCP catalog (TASK-1859)

Generalizes PLAN-1847's onboarding treatment to every invokable playbook.
The invocation-model intro now establishes that natural language is the
canonical way to invoke a playbook and `/pad`·`$pad`·`pad_playbook run` are
per-surface shortcuts; the greeting, the rendered intent-match message, the
examples preface, the plan/decompose routing entries, the "creating a
playbook" section, and the Planning/Decomposition workflow subsections all
lead with intent and label the slug forms as shortcuts. catalog_playbook.go's
tool description gets the same reframing.

Left untouched: the skill's own `/pad <anything>` entry-point syntax (that's
Claude Code skill invocation, not a playbook slug) and onboarding (already
NL-canonical from PLAN-1847). The plan/ideate/retro MCP prompts had no
`/pad <slug>` references.

Parent: PLAN-1858.

Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ

* fix(skill): use pad_playbook tool's action/ref form, not CLI-style 'run', per Codex review (round 1)

Round-1 review noted that 'pad_playbook run <slug>' reads as a non-existent
CLI command — pad_playbook is an MCP tool invoked with action: run, ref:
<slug>. Reworded all MCP-shortcut mentions in SKILL.md and the catalog tool
description to the structured tool-call form so agents don't try a bogus
command.

Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ
2026-06-21 15:22:41 -04:00
xarmian 09335c4916 feat(onboard): active "want me to set it up?" offer on needs_onboarding (TASK-1850) (#742)
Convert the passive needs_onboarding nudge into an active, lead-with-it
offer, mirrored across both agent-instruction surfaces:

- SKILL.md: the nudge rule now leads with "Want me to set it up?" and
  codifies offer-not-auto-run + respect-a-decline-for-the-session.
- internal/mcp/instructions.md: previously had NO needs_onboarding rule at
  all, so pure-MCP agents got the bootstrap flag but were never told to act
  on it. Added a "New workspace: offer to set it up" section with the same
  offer wording, pointing at the pad_onboard prompt / pad_playbook get
  ref:onboard to actually run onboarding once accepted.

Agent offers, never auto-runs. Agent-instruction surfaces only.

Parent: PLAN-1847.

Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ
2026-06-21 10:39:58 -04:00
xarmian 7962fabaeb feat(onboard): make natural language the canonical onboard trigger (TASK-1849) (#741)
* feat(onboard): make natural language the canonical onboard trigger (TASK-1849)

The onboard procedure already single-sourced to the seeded playbook body —
both SKILL.md and the MCP pad_onboard prompt deferred to it rather than
restating it. But two gaps remained:

- Both hardcoded `/pad onboard`, a Claude-Code-ism. SKILL.md installs into
  Codex (`$pad`) and others too, so the nudge/routing copy was wrong off
  Claude Code. Reframe NL ("set up my workspace") as the canonical trigger;
  `/pad onboard` · `$pad onboard` · the `pad_onboard` MCP prompt are now
  per-surface shortcuts into the same playbook.
- The MCP prompt resolved the playbook via CLI commands (`pad playbook
  list`/`show`) that a shell-less MCP client can't run. Rewrite it to use
  the `pad_playbook` tool (action:list / action:get ref:onboard); CLI form
  kept as a secondary note. This closes the real single-source gap.

Agent-instruction surfaces only (SKILL.md + MCP prompt text); no CLI/MCP
catalog changes (CONVE-1741).

Parent: PLAN-1847. Absorbs cancelled TASK-1848.

Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ

* fix(onboard): use pad_library activate for shell-less onboard recovery per Codex review (round 1)

Round-1 review flagged that the MCP prompt's missing-playbook fallback sent
agents to the web UI even though pad_library action=activate is callable from
a shell-less MCP client. Activate "Onboard a workspace" via the tool, then
re-list, before deferring to the user.

Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ
2026-06-21 10:37:14 -04:00
xarmian 96a32aa39d feat(bootstrap,skill): add needs_onboarding flag + retire legacy Onboarding workflow (TASK-1504,1505) (#578)
PLAN-1496's bootstrap-signal + skill-cleanup pair, shipped together
because TASK-1505's nudge rendering depends on TASK-1504's bootstrap
field.

TASK-1504 (bootstrap: needs_onboarding):
- internal/store/items.go: new WorkspaceHasUserCreatedItems(workspaceID)
  store method. Backed by SELECT EXISTS with the predicate
  `source != 'template'` — defined as the inverse of template seeding
  rather than enumerating user-side source values, so new attribution
  surfaces (mcp, api, future) count automatically.
- internal/server/handlers_bootstrap.go: AgentBootstrap struct gets
  the NeedsOnboarding bool field (always emitted — not omitempty,
  since the agent reads it on every /pad invocation). BuildAgentBootstrap
  computes it via the new store method. On query error the flag falls
  back to false (safe default: don't nag).
- Visibility filtering deliberately omitted — needs_onboarding is a
  workspace-level state signal, not a per-user view. Two members
  reading bootstrap concurrently should see the same answer.
- Two focused tests:
  - TestBootstrapNeedsOnboardingFlag walks the lifecycle (fresh
    workspace → create user item → flag flips).
  - TestBootstrapNeedsOnboardingIgnoresTemplateSeeds locks the
    template-seeds-don't-count invariant on the startup template,
    which ships seeded conventions, playbooks, and the onboard
    playbook itself.

TASK-1505 (skill update):
- skills/pad/SKILL.md:
  - Context Loading section: new bullet documenting needs_onboarding
    with the exact nudge wording the agent should render when true,
    plus the "don't nag past first user item" + "respect prior
    decline" rules.
  - Onboarding workflow section: deleted (~30 lines). Replaced with
    a one-paragraph pointer at the /pad onboard playbook. The skill
    is the dispatcher; the playbook body is the script.
  - Routing entry under "set up my workspace": simplified from the
    bloated PR #577 round-3/4 text into a clean two-bullet form
    (canonical phrasing + legacy IDEA-1 phrasing both → /pad onboard).
- internal/mcp/prompts_data.go: the pad_onboard MCP prompt body
  was duplicating the same step-by-step script the SKILL.md section
  carried. Replaced with the same dispatch-to-playbook pointer.
  internal/mcp/prompts_test.go: TestPromptsLockstep_CoreCommands
  fragments updated to assert the new dispatch fragments
  (`pad playbook list`, `pad playbook show onboard`).

Parent: PLAN-1496.
2026-05-17 13:57:12 -04:00
xarmian 0930743304 feat: retire IDEA-1 seed pattern + 'pad onboard' cobra + surface blank in init (TASK-1501/1502/1503) (#577)
* feat: retire IDEA-1 seed pattern + 'pad onboard' cobra + surface blank in init (TASK-1501,1502,1503)

PLAN-1496's legacy-onboarding teardown:

TASK-1501 (remove seed items + update banner):
- internal/collections/templates_onboarding.go (and the _product/_scrum
  siblings) deleted — these generated the IDEA-1/PLAN-2/TASK-3/DOC-4 +
  BACK-1/SPRINT-2/BUG-3/DOC-4 + FEAT-1/FB-2/ROAD-3/DOC-4 first-person
  seeds. The /pad onboard playbook (TASK-1499 / TASK-1500) is the
  replacement.
- startup/scrum/product templates: SeedItems lines removed.
- post-init banner in printOnboardingHints: now points at "/pad onboard"
  in one line, then web UI link, then dashboard hint. The "use pad to
  get IDEA-1 / BACK-1 / FEAT-1" branch is gone.

TASK-1502 (retire cobra + OnboardingPrimaryRef plumbing):
- OnboardingPrimaryRef struct field on WorkspaceTemplate removed. The
  dashboard's banner auto-discovers seeds via item_number=1 +
  source="template" + created_by="system", so the field was redundant
  even before retirement.
- onboardingPrimaryRef() helper in cmd/pad/main.go removed.
- 'pad onboard' Cobra subcommand removed (~160 lines). It scanned the
  project directory for build/test/CI markers and seeded library
  conventions — useful behavior but CLI-only, unreachable from
  MCP-only agents. The /pad onboard PLAYBOOK now covers it.
- internal/cli/detect.go and workspace_context_detect.go stay; still
  used by the web-side workspace-context save path.

TASK-1503 (Blank in interactive picker):
- The picker already surfaces Blank because templates_picker.go iterates
  GroupTemplatesByCategory, and the IDEA-1479 Blank template entry lives
  in CategoryCustom. Verified the output renders correctly with the
  TASK-1498 description + icon update.
- 'pad workspace init --help' Long now mentions Blank explicitly +
  points users at /pad onboard. Helps discoverability without restructuring
  the picker.

Test changes (delete or rewrite tests that exercised the retired pattern):
- internal/collections/templates_test.go: six tests deleted (StartupOnboardingItemsOrderAndShape,
  ScrumOnboardingItemsOrderAndShape, ProductOnboardingItemsOrderAndShape,
  Startup/ScrumProduct/TemplatesDeclareOnboardingPrimaryRef). New
  TestSoftwareTemplatesShipNoSeedItems replaces them with the inverse
  invariant: software templates ship zero seed items.
- internal/server/handlers_dashboard_test.go: three IDEA-1/BACK-1/FEAT-1
  expectation tests collapsed into TestDashboardOnboardingSeed_NilForAllTemplates,
  which asserts the auto-discovery finds no seed because seeds no longer
  ship. (Hiring + EmptyWorkspace tests untouched — they already expect
  nil for unrelated reasons.)
- internal/store/items_test.go: TestSeedCollectionsFromTemplate{Startup,Scrum,Product}RefSequence
  and TestOnboardingFlow_FullWalkthrough_{Startup,Scrum,Product} deleted;
  these locked the IDEA-1 ref-sequence + walkthrough behavior. Unused
  helpers (findItemByTitle, extractStatus, safeFields, setItemStatus,
  countItemsInCollection) deleted alongside them.
- internal/mcp/resources_test.go: TestReadItem_PreservesIDEAOneOnboardingBodyVerbatim
  → TestReadItem_PreservesBodyVerbatim. Property is the same (resource
  pipeline doesn't mangle markdown), but the fixture is now synthetic
  markdown instead of the IDEA-1 seed.

Note: handlers_dashboard.go still has the auto-discovery code path
(onboardingPrimaryCollectionSlugs map + the loop that probes for
item_number=1 + source="template"). It's now dead code — no item
will ever match the criteria after this PR. Left in place for a
follow-up cleanup pass to keep this PR focused.

Parent: PLAN-1496.

* docs: replace 'pad workspace onboard' references with /pad onboard (Codex round 1)

P2 finding on PR #577: README + CLAUDE.md still advertise the
'pad workspace onboard' subcommand in four places (README §Onboard
agents to a new codebase, README §3 Teach your agents the rules,
README CLI Reference, CLAUDE.md CLI). After this branch lands, those
instructions return "unknown command."

Replaced each with guidance pointing at /pad onboard (the playbook,
auto-seeded into every workspace). The library-list commands still
work and stay where they are.

Parent: PLAN-1496.

* docs: replace 'use pad to get IDEA-1' guidance with /pad onboard (Codex round 2)

P1 finding on PR #577: README.md:33-39 and CLAUDE.md:111-117 still
told users to 'use pad to get IDEA-1' after the post-init banner.
Since this branch deletes templates_onboarding.go and stops seeding
IDEA-1/PLAN-2/TASK-3/DOC-4, the quickstart instructions in both
top-level docs pointed at items that no longer exist.

Replaced each with /pad onboard guidance (the playbook is auto-seeded
into every new workspace by TASK-1500). CLAUDE.md's CLI reference
gets a one-line historical note explaining the pre-PLAN-1496 IDEA-1
pattern so readers reviewing older code/blame have context.

Parent: PLAN-1496.

* docs(skill): retire 'use pad to get IDEA-1' guidance in agent skill (Codex round 3)

P1 finding on PR #577: skills/pad/SKILL.md:175 still taught agents
that '"use pad to get IDEA-1"' should dispatch to 'pad item show IDEA-1'.
This branch deletes the seed items, so any agent following the
shipped skill in a fresh workspace would try to fetch a missing ref
instead of running /pad onboard.

Updated the routing entry to dispatch the legacy phrasing (kept as a
recognized intent so older docs/conversations still work) to the
/pad onboard playbook. Explicit "do NOT try to fetch IDEA-1
directly" to short-circuit the previously-trained behavior.

A broader skill cleanup — removing the standalone Onboarding
workflow section and adding the bootstrap nudge rendering — is
TASK-1505's scope. This PR's update is the minimal change needed to
unbreak the agent-facing routing.

Parent: PLAN-1496.

* docs(skill): add library-activation caveat to onboard routing entry (round 4)

P2 finding on PR #577: the routing entry said /pad onboard is
'always invokable because every workspace auto-seeds it.' True for
newly-created workspaces, but pre-existing workspaces (created before
PLAN-1496 lands) won't have it. Auto-upgrade is intentionally not
wired into SeedCollectionsFromTemplate for empty-template-name paths.

Mirrored the same activation-fallback caveat /pad plan and
/pad decompose carry: 'activate via library if the bootstrap's
playbooks array lacks invocation_slug=onboard, status=active.'

Parent: PLAN-1496.
2026-05-17 13:40:15 -04:00
xarmian aaa581b105 refactor(bootstrap): extend dashboard caps to active_items/active_plans/by_role (TASK-1422) (#541)
* refactor(bootstrap): extend dashboard caps to active_items/active_plans/by_role/suggested_next (TASK-1422)

Implements IDEA-1421 (absorbed into PLAN-1410's v0.4 envelope). Extends
the BootstrapDashboard wrapper from TASK-1413 to cap four more
dashboard sub-arrays with parallel overflow counts, same shape and
semantics as the existing attention/recent_activity caps.

## Struct + caps

Four new int fields on BootstrapDashboard (all `,omitempty`):

  - active_items_overflow_count
  - active_plans_overflow_count
  - by_role_overflow_count
  - suggested_next_overflow_count

Four new cap constants alongside the existing two:

  - bootstrapActiveItemsCap   = 5
  - bootstrapActivePlansCap   = 5
  - bootstrapByRoleCap        = 5
  - bootstrapSuggestedNextCap = 5

capBootstrapDashboard extended with four parallel truncate-and-count
blocks — same shallow-copy mutation pattern, source pointer
untouched (the dashboard endpoint still returns its full-length
arrays per its own contract).

## Tests

TestCapBootstrapDashboard rewritten to cover all six caps under one
contract. `mk` now takes a `dashCounts` struct (Att/Rec/Items/Plans/
Role/Sugg) so each subtest exercises specific caps without populating
the others. Each of the four existing subtests (under-cap-no-overflow,
over-cap-truncates-and-counts-overflow, source-pointer-unchanged,
exact-cap-no-overflow) now asserts the new caps too. Added tiny
assertLen/assertOverflow helpers to keep the per-array assertion
noise from drowning the contract being tested.

bootstrapSectionBytes extended to surface the four new cap-effect
lines when triggered, table-driven so future caps drop in cleanly.

seedBootstrapSizeFixture updated to seed 6 in_progress tasks (was 5
open) so the new active_items cap fires visibly in the per-section
breakdown: "active_items capped: 5 shown, 1 overflow". The status
flip is deliberate — dashboard.active_items filters on
isActiveStatus(), which excludes initial/terminal statuses; open
tasks never appeared in the section.

## Budget

bootstrapSizeBudget 7 KiB → 9 KiB. Note that this is FIXTURE-side
growth, not shape-side regression: the fixture now seeds enough
active items to exercise the new cap (active_items section was 0
bytes when tasks were status=open). The cap itself is purely a
SAVINGS — on docapp it drops active_items from 7 → 5 entries with
overflow_count=2.

Budget history note in handlers_bootstrap_test.go updated with the
TASK-1422 line and an explicit "fixture-side, not shape-side"
explanation so future readers understand why the budget moved up.

## Out of scope

- Slim BootstrapRole projection — TASK-1423.
- Schema label + sort_order trim — TASK-1424.
- ToolSurfaceVersion 0.3 → 0.4 bump — TASK-1418 (final PR).

Parent: PLAN-1410. Resolves IDEA-1421 once merged.

* fix(bootstrap): drop unreachable suggested_next cap (TASK-1422 follow-up)

Address Codex P1 finding on PR #541: `suggested_next_overflow_count`
was unreachable in production responses because
`buildDashboardResponse` already truncates `SuggestedNext` to 3
upstream (see "Take top 3" comment in handlers_dashboard.go:854-858),
while my bootstrap cap was 5. The cap-and-overflow logic could only
have fired against synthetic test state, never against the real
dashboard pipeline.

Two responses to consider:

  1. Lower bootstrap's cap to a number smaller than 3 — defeats the
     upstream design choice (3 IS the intentional limit).
  2. Drop the bootstrap-side cap — clean, no dead surface.

Going with (2). If the upstream cap is ever raised or removed,
that's the moment to add a suggested_next_overflow_count back.

Removed:

  - SuggestedNextOverflowCount field on BootstrapDashboard
  - bootstrapSuggestedNextCap constant
  - The truncate-and-count block in capBootstrapDashboard
  - The suggested_next row in TestCapBootstrapDashboard's dashCounts
    helper and all five subtest assertions
  - The suggested_next entry in bootstrapSectionBytes's cap-line loop

The fixture still seeds tasks and a plan, so the no-cap path on
SuggestedNext is naturally exercised through TestBootstrapSizeBudget.
The godoc on BootstrapDashboard now explicitly calls out the
exclusion + the upstream-cap rationale so a future reader knows
why suggested_next is missing from the otherwise-uniform cap set.

Parent: PLAN-1410 / TASK-1422.

* docs(skill): align SKILL.md dashboard cap description with TASK-1422

Address Codex P3 finding on PR #541: the SKILL.md `Context Loading`
section described only the two original cap fields
(attention_overflow_count, recent_activity_overflow_count). After
TASK-1422 the bootstrap response carries three more
(active_items_overflow_count, active_plans_overflow_count,
by_role_overflow_count), and the agent needs to know to pull the
full set via `pad project dashboard` when any of them are > 0.

Updated the bullet to enumerate all five capped sub-arrays and
state the overflow-field pattern generically rather than per-field.
Same in-PR-sync pattern used for TASK-1413, TASK-1415, TASK-1416.

Parent: PLAN-1410 / TASK-1422.

* docs(bootstrap): fix three stale comments after dropping suggested_next cap (TASK-1422 follow-up)

Address Codex P3 finding on PR #541 round 3: three stale doc strings
referenced the old shape (with suggested_next) after the cap was
dropped in the prior commit. Updated:

1. handlers_bootstrap_test.go budget-history line for TASK-1422 —
   removed `suggested_next` from the cap list and added the
   "deliberately excluded — already capped to 3 upstream" rationale
   so future readers know why the otherwise-uniform cap set is
   missing one.

2. handlers_bootstrap.go BootstrapDashboard godoc — changed
   "two overflow counts" to "five overflow counts (one per capped
   sub-array)".

3. handlers_bootstrap.go capBootstrapDashboard godoc — changed
   "both caps are untriggered" to "all caps are untriggered".

Tidy-up only, no behavior change. Same skill-↔-code sync hygiene
that has been the running theme across PLAN-1410's review loops.

Parent: PLAN-1410 / TASK-1422.

* docs(bootstrap): update remaining stale call-site comment for capBootstrapDashboard (TASK-1422 follow-up)

Final stale-doc cleanup per Codex P3 on PR #541 round 4: the
BuildAgentBootstrap dashboard-wrapping call-site comment still
listed only attention + recent_activity. Updated to enumerate all
five capped sub-arrays for parity with the godoc on
BootstrapDashboard / capBootstrapDashboard.

Same hygiene as the previous commit; no behavior change.

Parent: PLAN-1410 / TASK-1422.
2026-05-13 17:10:56 -04:00
xarmian 6a981e7433 docs(skill): compress NL Routing examples, trim playbook authoring, drop MCP note (TASK-1416) (#539)
* docs(skill): compress NL Routing examples, trim playbook authoring section, drop MCP note (TASK-1416)

Final SKILL.md trim in PLAN-1410's skill-side compression series.
Three targeted changes:

1. NATURAL LANGUAGE ROUTING — compress example density

   The "example phrasing → command" pairs in each sub-category were
   over-enumerated — the model handles intent matching without an
   exhaustive lookup table. Compressed each sub-category to its
   canonical pattern(s) while keeping the section structural map
   intact (Role management, Creating items, Querying, Updating,
   Working with attachments, Planning, Ideation, Dependencies,
   Reports, Retrospective, Onboarding, Creating a playbook).

   Pattern: where a section had 5-11 "intent → command" lines all
   illustrating the same command verb with slight wording variants,
   collapsed to a 1-2 line summary that describes the routing rule
   directly. Where a section had genuinely-distinct commands
   (e.g. Querying covers dashboard / next / list / search), kept
   one canonical line per command verb.

2. PLAYBOOK AUTHORING — replace worked examples with a compact pointer

   The "Authoring trigger-only" and "Authoring slug-invocable with
   arguments" subsections previously included full ~25-line heredoc
   examples — useful when the schema-aware --field parsing was new,
   now memorizable scaffolding. Replaced with a 2-line pointer
   listing the two authoring surfaces (CLI / Web UI) and the key
   `--field 'arguments=[...]'` shape. The model knows the heredoc
   pattern; the worked example was redundant.

3. MCP NOTE — dropped

   The "Note for agents using MCP instead of this skill" preamble
   (~700 B) only applied to readers of the file — agents loading
   this skill are by definition using the CLI surface, not MCP.
   The MCP catalog reference at getpad.dev/mcp/local stays the
   canonical source for that surface; removing the note avoids
   carrying its bytes in every skill load.

Measurements:

  Before TASK-1416: 34,786 b / 479 lines
  After:           29,448 b / 393 lines
  Delta this PR:   -5,338 b (-15%)

Cumulative PLAN-1410 skill-side reduction (TASK-1414/1415/1416):

  Baseline (TASK-1410 start): 40,193 b / 567 lines
  After all three trims:      29,448 b / 393 lines
  Total reduction:            -10,745 b (-26.7%)

Parent: PLAN-1410. Remaining: TASK-1417 (final measurement back
into the plan body) and TASK-1418 (ToolSurfaceVersion 0.3 → 0.4).

* fix(skill): add status=active activation requirement to playbook authoring (TASK-1416 follow-up)

Address Codex P2 finding on PR #539: the compressed authoring
pointer dropped `--field status=active` from the example. New
playbooks default to status=draft, but slug routing and
trigger-intent matching only dispatch status=active entries.
Following the trimmed instructions would create /pad <slug>
playbooks that silently fall through to NL routing.

The original (pre-TASK-1416) worked example included status=active;
I lost it when collapsing to a pointer. Restored explicitly:

  - New "**Activation matters**" paragraph calling out the default-draft
    pitfall and the silent-fall-through behavior.
  - CLI example now includes `--field status=active`.
  - Web UI bullet explicitly mentions flipping draft → active
    before save.

Same in-PR-sync pattern as TASK-1413's SKILL.md alignment commit
and TASK-1415's activation-check correction.

Parent: PLAN-1410 / TASK-1416.
2026-05-13 16:16:50 -04:00
xarmian 335b145343 docs(skill): replace Planning/Decomposition workflow fallbacks with one-liner playbook pointers (TASK-1415) (#538)
* docs(skill): replace Planning/Decomposition workflow fallbacks with one-liner playbook pointers (TASK-1415)

The Planning and Decomposition workflows in SKILL.md each had:

  1. A leading "use the <slug> playbook" pointer (the canonical
     entry point).
  2. A multi-line "if the playbook isn't activated, fall back to
     this inline workflow" block duplicating most of the playbook's
     contract.

For software templates the playbooks auto-seed via
softwareStarterPlaybookTitles, so the fallback fires approximately
never. Non-software workspaces activate from the library UI, which
also makes the fallback transient at best.

Replaced each section with a one-liner pointer that:

  - Names the canonical /pad <slug> invocation
  - Explains how to confirm activation (bootstrap's playbooks array
    or `pad playbook show <slug>`)
  - Tells the user to activate via the library UI when missing,
    and offer to walk through manually in the meantime — without
    duplicating the playbook's step contract here

Measurements:

  SKILL.md total: 36,473 → 34,786 bytes (-1,687 b / -5%)
  SKILL.md lines:    501 →    479

Cumulative against PLAN-1410 baseline:

  SKILL.md total: 40,193 → 34,786 bytes (-13.5% so far)

Parent: PLAN-1410. Next: TASK-1416 (NL Routing + authoring example + MCP note).

* fix(skill): correct playbook activation-check guidance (TASK-1415 follow-up)

Address Codex review findings on PR #538:

  P2 — `pad playbook show <slug>` is not a valid activation check.
  The resolver returns playbooks by invocation_slug regardless of
  status, and default output omits status. A draft/deprecated
  `plan` playbook would be treated as active. Corrected to direct
  the agent at the bootstrap's `playbooks` array (which carries
  status) for the activation check, and noted explicitly that
  `pad playbook show` alone is insufficient.

  P3 — The "**Planning:**" routing bullet still pointed at
  "otherwise inline workflow (see below)" after the inline
  fallbacks were removed in the parent commit. Replaced with a
  pointer to library activation, matching the (now-trimmed)
  workflow section.

Both findings would have left agents in a workspace without
active plan/decompose playbooks pointing at the wrong fallback.
Fixed in-PR so skill ↔ playbook contract stays strictly
synchronized (same pattern as TASK-1413's SKILL.md sync commit).

Parent: PLAN-1410 / TASK-1415.
2026-05-13 16:08:29 -04:00
xarmian efccea6dfe docs(skill): compress CLI Reference to patterns the skill drives (TASK-1414) (#537)
The CLI Reference section grew over time with every-flag enumeration,
multiple worked examples per command, and edge-case commands the
skill never invokes (webhooks REST API trivia, full --fields DSL
walkthrough for collection create, multiple per-command examples
that all illustrate the same flag pattern).

Compressed to the patterns the natural-language routing actually
drives, with `pad <cmd> --help` as the explicit escape hatch for
anything else.

Measurements:

  - SKILL.md total:        40,193 → 36,473 bytes (-3,720 b / -9%)
  - SKILL.md line count:   567    → 501
  - CLI Reference section: ~6,500 → ~3,400 bytes (-48%)

Preserved:

  - All command verbs the NL routing references (item create/list/
    show/update/delete/search/comment/comments/bulk-update, role
    list/create/delete, project dashboard/next/standup/changelog,
    playbook list/show/run, attachment list/show/view/upload/
    download, collection list/create, server info/open, auth whoami,
    bootstrap).
  - The hard rule against reading ~/.pad/attachments/ directly
    (kept the explanation tight: "bypasses ACLs, breaks on Pad
    Cloud / S3, skips the variant pipeline").
  - The `--field key=value` schema-aware pattern with concrete
    examples for convention + playbook (the two collections most
    likely to drive its use).
  - The two-mode collection create (`--fields` DSL vs `--schema`
    full CollectionSchema), with the when-to-use guidance retained.

Dropped or compressed:

  - Per-command multi-example listings (one canonical pattern each).
  - The full Webhooks subsection — webhooks are REST-API-only and
    the skill never invokes them directly; pointer in the catch-all.
  - The trailing "Output Formats" footer — replaced by an inline
    note at the section header that --format json works everywhere.
  - Verbose per-line comments inside code blocks; the patterns are
    self-documenting at this scale.

Parent: PLAN-1410 / TASK-1414. Bootstrap-shape work already in main
(TASK-1411/1412/1413). Remaining: SKILL.md workflow + NL routing
trims (TASK-1415/1416), then final measurement + version bump.
2026-05-13 16:02:04 -04:00
xarmian 638a456f98 refactor(bootstrap): dedup recent_activity, drop convention slug, cap dashboard arrays (TASK-1413) (#536)
* refactor(bootstrap): dedup recent_activity, drop convention slug, cap dashboard arrays (TASK-1413)

Three bundled handler-level cleanups against PLAN-1410's bootstrap
shape. Total fixture savings: 8,992 → 6,355 bytes (-2,637 b / -29%).

1. Drop duplicate top-level `recent_activity`

   AgentBootstrap.RecentActivity was bit-for-bit identical to
   AgentBootstrap.Dashboard.RecentActivity. Removed:

     - AgentBootstrap.RecentActivity field
     - capRecentActivity() helper
     - recentActivityWindow constant
     - the time import (no longer used)

   Fixture savings: -1,751 bytes.

2. Drop `slug` from AgentBootstrapConvention

   Agents address convention items by ref (CONVE-N); slug was dead
   weight. Removed the field + the population line in
   collectAlwaysOnConventions.

   Fixture savings: -78 bytes.

3. Cap dashboard.attention + dashboard.recent_activity to 5 in bootstrap

   New BootstrapDashboard wrapper embeds *DashboardResponse (so the
   wire shape stays compatible — same field names, same nesting) and
   adds two overflow counts:

     - attention_overflow_count       (omitempty when zero)
     - recent_activity_overflow_count (omitempty when zero)

   The cap is applied via capBootstrapDashboard which shallow-copies
   the DashboardResponse before truncating the slices, so callers
   downstream of buildDashboardResponse (the dashboard endpoint
   itself, the web UI) see their original full-length arrays
   unchanged. `pad project dashboard` contract is preserved verbatim.

   Fixture savings: -789 bytes (recent_activity capped 9 → 5;
   attention untouched, fixture has 0 attention items).

Coverage:

  - TestCapBootstrapDashboard (4 subtests): under-cap-no-overflow,
    over-cap-truncates-and-counts-overflow, source-pointer-unchanged,
    exact-cap-no-overflow. Locks in the cap contract independent of
    the full bootstrap pipeline.
  - TestBootstrapEmptyArraysNotNull updated: the top-level
    recent_activity key was removed from the required-keys list,
    with a separate assertion that guards against it reappearing.
  - TestBootstrapEmptyWorkspace updated: removed the b.RecentActivity
    nil-check; added a (defensive) check that dashboard's nested
    recent_activity is non-nil when dashboard is present.
  - bootstrapSectionBytes now surfaces the cap effect ("attention
    capped: 5 shown, 4 overflow") when triggered, so the trim's
    value is legible from CI output.

bootstrapSizeBudget tightened 11 KiB → 8 KiB to lock in the win.
Budget-history comment updated.

Out of scope (handled by later PLAN-1410 PRs):

  - Skill-file trim (TASK-1414/1415/1416)
  - Final measurement (TASK-1417)
  - ToolSurfaceVersion 0.3 → 0.4 (TASK-1418, after all shape
    changes land)

Parent: PLAN-1410.

* docs(skill): align SKILL.md bootstrap shape with PLAN-1410 / TASK-1413

The skill's `Context Loading` section described the old wire shape:

  - `dashboard {...}` — active items, attention, suggested next, recent activity
  - `recent_activity [...]` — capped to the last 24h

After TASK-1413 the top-level `recent_activity` field is gone (it was
a bit-for-bit duplicate of `dashboard.recent_activity`), and the
remaining `dashboard.recent_activity` is capped by COUNT (top 5) not
by TIME (24h window). The two cap fields (attention_overflow_count
and recent_activity_overflow_count) tell the agent how much was
trimmed so it can decide whether to follow up with a full
`pad project dashboard` query.

Per the Codex P2 finding on PR #536: documenting the new contract
in this PR keeps skill ↔ wire-shape strictly synchronized (no
window where the docs are wrong about the shape this PR ships).

Parent: PLAN-1410 / TASK-1413.
2026-05-13 15:29:59 -04:00
xarmian 1db0e6a505 docs+test: closes the PLAN-1397 library overhaul loop (TASK-1404) (#533)
* docs+test: closes the PLAN-1397 library overhaul loop (TASK-1404)

## Tests

New `internal/collections/playbook_library_test.go` with 4 regression
guards for the invokable-first library:

- TestPlaybookLibrary_InvokableEntriesPresent — asserts ship + plan +
  decompose are all present by invocation_slug, that each has at
  least one Argument declared, and that at least 3 invokable entries
  exist. Catches T1 widening getting half-reverted or T6's library
  rebuild dropping an entry.
- TestPlaybookLibrary_AllTriggersKnown — asserts every library
  entry's Trigger is one of the canonical values (manual,
  on-implement, on-review, on-plan, on-triage, on-release,
  on-deploy, on-pr-create, on-task-complete, always). Catches typos
  that would seed an invalid trigger.
- TestPlaybookLibrary_ShipBodyShared — confirms the library `ship`
  entry and ShipPlaybook() seed share the same body constant. The
  whole point of T3 was to avoid duplication; this prevents drift.
- TestPlaybookLibraryArchive_BodiesCompiled — keeps archivedPlaybooks()
  greppable and verifies the canonical retired title ("Implementation
  Workflow") is still in the archive.

## CLAUDE.md

- Added a "Library — discovery surface" subsection to the Playbooks
  section explaining the invokable-first lineup, the archive, and
  how `softwareStarterPlaybookTitles` seeds plan + decompose into
  software workspaces.
- Expanded "Code map" with the four new/refactored library files
  (playbook_library.go, playbook_library_plan.go,
  playbook_library_decompose.go, playbook_library_archive.go).
- Cross-referenced PLAN-1397 alongside PLAN-1377 for design history.

## skills/pad/SKILL.md

- "Planning: 'Let's create a plan'" and "Decomposition: 'Break plan
  X into tasks'" now name `/pad plan` and `/pad decompose` as the
  canonical entry points, with the inline workflow as the fallback
  when those playbooks aren't activated in the workspace.
- The high-level "Planning:" intent list at the top of the routing
  section updated to match.
- Note: SKILL.md is embedded into the Go binary at build time. The
  next `make install` distributes the skill update; the .agents/
  copy regenerates automatically.

Parent: PLAN-1397. Closes the loop — all 7 tasks of the playbook
library overhaul shipped.

* fix(test): source knownPlaybookTriggers from SoftwarePlaybookTriggers per Codex review (round 1)

Round 1: the hand-maintained `knownPlaybookTriggers` map admitted
`on-pr-create`, `on-task-complete`, and `always` — all valid in
the schema for *conventions*, but NOT in `SoftwarePlaybookTriggers`
(templates.go:266). A library entry could ship one of those and
the regression test would pass, but the same trigger would be
rejected when seeded into a software workspace at activation.

Fix: derive the test's known-set from `SoftwarePlaybookTriggers`
directly. If the schema widens or narrows, the test follows
automatically — no drift between the assertion baseline and the
actual schema. Also added a note clarifying that templates with
domain-specific trigger vocabularies (e.g. hiring's
on-candidate-advance) would need their own library scoped to
that template.

The set is now exactly: manual, on-implement, on-triage,
on-release, on-plan, on-review, on-deploy.
2026-05-13 07:17:58 -04:00
xarmian c2014fa7f8 fix(cli): schema-aware --field parsing for non-string typed fields (BUG-1125)
pad item create/update --field key=value previously stored every value
as a string, so json / number / checkbox / multi_select fields were
rejected by the server-side validator. The new parseFieldFlag helper
fetches the collection schema once per command and parses each value
according to its declared field type:

- json / multi_select → json.Unmarshal
- number → strconv.ParseFloat
- checkbox → strconv.ParseBool
- text / url / select / date / relation / unknown → raw string

Schema-fetch failure degrades gracefully to pre-fix string-only behavior.
pad item list --field is unchanged (URL query param, not validator).

Verified against both repros: --field reading_time=3 on blogs (the
original number case) and --field 'arguments=[{...}]' on playbooks (the
json case that surfaced authoring the ship playbook). String fields show
no regression.

Skill update folded in: the "Authoring slug-invocable playbooks with
arguments" section in skills/pad/SKILL.md previously routed users to the
web editor as the only path for structured arguments. With this fix the
CLI handles it in one command, so the section now leads with the CLI
flow and demotes the web editor to an alternative.
2026-05-13 03:54:50 +00:00
xarmian 9764b2fe92 docs: document playbook invocation surface (TASK-1387) (#525)
* docs: document playbook invocation surface (TASK-1387)

Closes out PLAN-1377 — Make Playbooks first-class invokable procedures —
by bringing the four user-facing docs surfaces up to date with the
shipped invocation model. The pad-web docs ship in a separate commit
(../pad-web@main: docs: document playbook invocation surface).

- CLAUDE.md — new Playbooks section after Data Model covering the
  three invocation surfaces, the invocation_slug/arguments schema
  fields, bootstrap-returns-metadata, the seeded ship playbook, the
  web UI editor, and a code map. MCP section grows pad_playbook,
  pad://workspace/{ws}/bootstrap, and the pad_set_workspace embedded
  response note.
- skills/pad/SKILL.md — adds a "Creating a playbook" subsection under
  natural-language routing with CLI examples for trigger-only and
  slug-invocable playbooks, plus a Playbooks block in the CLI
  reference (pad playbook list/show/run with parsing rules).
- README.md — one-line bump in the feature list mentioning the new
  /pad <slug> invocation form and the seeded ship playbook.

Parent: PLAN-1377.

* fix(docs): correct bootstrap route + CLI arguments authoring per Codex review (round 1)

Codex round 1 findings:

[P2] CLAUDE.md cited GET /api/v1/workspaces/{ws}/bootstrap but the
implemented route is /api/v1/workspaces/{ws}/agent/bootstrap (server.go
line 1182). Documented endpoint would 404 for HTTP integrators.

[P2] SKILL.md '--field arguments=[...]' example would fail validation
— pad item create stores all --field values as strings, while
arguments is a json field type. Rewrote the slug-invocable-playbook
authoring guidance to direct agents at the web UI editor for
structured argument authoring (the canonical path the editor was
built for) with the CLI handling everything else. Same fix applied
to the pad-web /docs/agent-integration page in a separate
../pad-web@main commit.

Parent: TASK-1387 / PLAN-1377.

* fix(docs): use full /{username}/{workspace}/playbooks route in SKILL.md per Codex review (round 2)

Codex round 2 finding:

[P3] SKILL.md's recommended web editor path was '/{workspace}/playbooks',
but the SvelteKit route is '/{username}/{workspace}/playbooks'. The
prior path would 404 or land on the wrong workspace. Fixed.

The pad-web docs ship the matching fix in a separate commit at
../pad-web@main: docs(playbooks): use full /{username}/{workspace}
route path per review.

Parent: TASK-1387 / PLAN-1377.

* fix(docs): bump CLAUDE.md MCP tool surface to v0.3 + close SKILL.md backtick per Codex review (round 3)

Codex round 3 findings:

[P2] CLAUDE.md still labelled the tool surface as v0.2; internal/mcp/
version.go advertises ToolSurfaceVersion = '0.3' (since PLAN-1377 /
TASK-1380). Updated to v0.3 and added a note about what v0.3 introduced
(pad_meta.action: bootstrap, pad_set_workspace embedded-bootstrap
response, pad://workspace/{ws}/bootstrap resource).

[P3] SKILL.md's web-editor route had the parenthetical inside the
code span: '`/{username}/{workspace}/playbooks (click "+ New
Playbook")`' — closed the backtick after '/playbooks' so the
rendered code span is the literal path.

The pad-web docs ship the matching v0.3 bump in a separate commit at
../pad-web@main: docs(mcp/tools): bump tool surface to v0.3.

Parent: TASK-1387 / PLAN-1377.

* fix(docs): bump stale MCP catalog references to v0.3 per Codex review (round 4)

Codex round 4 finding [P2]:

Three places still described the MCP catalog as v0.2, contradicting the
v0.3 surface block that landed in this PR:

- CLAUDE.md 'MCP server' lede paragraph — bumped to v0.3, added
  pad_playbook to the listed tools, and noted what v0.3 introduced.
- skills/pad/SKILL.md MCP note for MCP-using agents — bumped to v0.3,
  added pad_playbook to the listed tools and called out the playbook
  invocation surface + bootstrap action.
- README.md 'Tool catalog (v0.2)' block — bumped to v0.3, added the
  pad_playbook row, the pad_meta.action: bootstrap row, the bootstrap
  resource, and bumped tool_surface_version.

Parent: TASK-1387 / PLAN-1377.

* fix(docs): bump MCP server-side self-description to v0.3 per Codex review (round 5)

Codex round 5 finding [P2]:

Two MCP-server-internal documentation surfaces still advertised v0.2:

- internal/mcp/instructions.md — the markdown blob the server returns
  to MCP clients as initialization instructions. Updated 'Tool surface
  (v0.2) / Eight tools' to 'Tool surface (v0.3) / Nine tools', added
  pad_playbook with list/get/run, added bootstrap to pad_meta's actions,
  noted pad_set_workspace's embedded-bootstrap response, and added
  pad://workspace/{ws}/bootstrap to the resource list.
- internal/mcp/catalog_meta.go — the pad_meta tool's Description string
  said 'v0.2 tool catalog' twice. Bumped both to v0.3.

These ship inside the binary; MCP clients read them directly so v0.2
mentions there contradict the v0.3 catalog the handshake actually
advertises (ToolSurfaceVersion in version.go).

Parent: TASK-1387 / PLAN-1377.

* fix(docs): finish MCP self-description v0.3 cleanup per Codex review (round 6)

Codex round 6 findings [P3]:

[1] catalog_meta.go's padMetaTool block-comment said 'Three actions,
all handled inline' even though bootstrap (the v0.3 fourth action)
dispatches through env.Dispatch. Fixed both the count and the
dispatch description, added the bootstrap row to the action list.
Also corrected the v0.2 mentions in actionMetaToolSurface's comment
and removed the rollout-era language now that the cmdhelp walker is
retired.

[2] instructions.md said 'Nine tools, each with an action enum' but
pad_set_workspace doesn't take an action. Clarified the count as
'eight resource × action tools, plus pad_set_workspace (which takes
a workspace slug only)' and scoped the 'Always pass action' rule to
the eight resource × action tools.

Parent: TASK-1387 / PLAN-1377.

* fix(docs): finish MCP self-description nine-tool wording per Codex review (round 7)

Codex round 7 findings:

[1] catalog_meta.go's padMetaToolDescription still mentioned the
PLAN-969-rollout cmdhelp walker contributing to tools/list. The walker
was retired in TASK-981. Rewrote the tool-surface action description
to match current behavior and explicitly note pad_set_workspace is
registered separately (not enumerated by tool-surface).

[2] actionMetaToolSurface's comment claimed scope includes
pad_set_workspace; the impl only loops env.Catalog. Updated the
comment to be accurate — tool-surface enumerates the eight catalog
tools only, callers should account for pad_set_workspace as a known
extra.

[3] CLAUDE.md and README.md described the MCP surface as if every
listed tool was resource × action. pad_set_workspace takes
'workspace' only. Reworded both to match instructions.md's
'eight resource × action tools plus pad_set_workspace' framing.

Parent: TASK-1387 / PLAN-1377.
2026-05-12 21:44:45 -04:00
xarmian 955553b6c2 docs(skill): rewrite /pad skill for bootstrap + slug routing (TASK-1383) (#522)
* docs(skill): rewrite /pad skill for bootstrap + slug routing (TASK-1383)

PLAN-1377 T6. Three substantive changes to skills/pad/SKILL.md:

1. Context Loading section now uses a single `pad bootstrap --format
   json` call instead of four separate ones (project dashboard,
   collection list, conventions list, role list). Documents the
   AgentBootstrap struct's shape and explains why one call beats
   four (~200-400ms saved per /pad invocation, stable shape,
   no view stitching).

2. New "Playbook Invocation (slug routing)" section ahead of the
   natural-language routing. Spells out the rule: if the first token
   after /pad is an exact match against a kebab-case invocation_slug
   from the bootstrap's playbooks array, dispatch to that playbook.
   Otherwise fall through to NL routing. Examples cover /pad ship
   PLAN-1377, /pad release 0.5.0, /pad draft-tweet TASK-X
   platforms=x,bluesky, and the kebab-case discipline that keeps
   /pad let's discuss IDEA-3 from misrouting.

3. "Before Performing Work" reframes the trigger-specific
   convention/playbook lookup: bootstrap already carries the
   always-on conventions + full playbook metadata, so the only
   on-demand load is trigger-matched conventions. Schema vocabulary
   reads from the bootstrap's collections[] payload, not a separate
   `pad collection list` call.

Greeting note added: if bootstrap returns any playbooks with
invocation_slug, surface the user-callable set ("Playbooks available:
/pad ship, /pad release, ...") so users discover what's invokable —
same shape as the existing roles greeting.

Parent: PLAN-1377.

* fix(skill): filter playbook routing/greeting on status=active (TASK-1383)

Codex round 1: the skill's playbook routing rule and greeting surfaced
every entry with an invocation_slug, but bootstrap returns all
playbooks regardless of status (so draft/deprecated entries with a
slug got advertised as runnable). The skill now explicitly requires
status=active for both the greeting list and the slug dispatch
decision — drafts can keep their slug while in-flight without
accidentally firing.

Parent: PLAN-1377.

* fix(skill): trigger-based intent match also filters status=active (TASK-1383)

Codex round 2: the slug-routing fix from round 1 didn't extend to the
trigger-based intent-matching branch, so 'let's do a release' could
still surface a draft on-release playbook. Apply the same status=active
filter consistently.

Parent: PLAN-1377.
2026-05-12 19:22:43 -04:00
xarmian 9fecb82e82 docs: surface --schema flag in SKILL.md + CLAUDE.md (TASK-1336) (#484)
PR #482 / TASK-1334 added the --schema flag to `pad collection create`.
PR #483 / TASK-1335 wired it through MCP. Agents reading the live
SKILL.md (embedded in the binary) need to see the new path or they'll
keep reaching for --fields DSL and hit the original BUG-1284 symptom.

Updates:

  - skills/pad/SKILL.md "Collections" section: now shows both --fields
    DSL and --schema JSON forms side-by-side. Calls out exactly when
    --schema is required (terminal_options et al.), and notes the
    label-from-key auto-fill so agents know empty labels are safe.

  - CLAUDE.md command list: adds the --schema variant alongside the
    existing --fields example so the project-level cheat sheet stays
    accurate.

The CLI Long help (`pad collection create --help`) and the MCP tool
description were already updated in TASK-1334 / TASK-1335.

Parent: PLAN-1333.
2026-05-10 21:38:26 -04:00
xarmian d1fb61097e docs(onboarding): document IDEA-1 trigger phrase across README, CLAUDE.md, and /pad skill (TASK-1138) (#406)
* docs(onboarding): document the IDEA-1 trigger phrase across README, CLAUDE.md, and the /pad skill (TASK-1138)

Make the seeded onboarding entry point (PLAN-1131) discoverable in
every doc surface a fresh user might land on.

README.md
  Quick Start gains a follow-up paragraph after `pad init`. Names the
  trigger phrase verbatim so a copy-paste lands deterministically. Tone
  matches in-product hint copy from PR #403; no "tutorial" / "lesson"
  language.

CLAUDE.md
  Authentication section gets a paragraph after `pad auth setup`
  pointing developers + agents at the same trigger phrase. Also
  enumerates the four seeded refs (IDEA-1 / PLAN-2 / TASK-3 / DOC-4)
  for context, with pointers to the source-of-truth code
  (internal/collections/templates_onboarding.go) and design history
  (PLAN-1131).

skills/pad/SKILL.md
  Adds a bullet under the Onboarding routing section: an explicit
  "use pad to get IDEA-1" trigger and the schema-aware terminal-status
  guidance per collection (Ideas → implemented, Plans → completed,
  Tasks → done, Docs → archived). Frames the seed items as ordinary
  items the agent reads and acts on — no "onboarding mode" — so the
  no-marker / no-skill-detection design from PLAN-1131 stays clean.

pad-web (../pad-web) is intentionally not touched — separate repo per
CONVE-159. Spawned TASK-1142 to pick up the pad-web getting-started
flow as a follow-up.

Parent: PLAN-1131. Origin: IDEA-1128.

* fix(docs): scope the IDEA-1 hint to post-workspace-creation, not bootstrap setup, per Codex review (round 1)

Codex caught that the original wording suggested users could go straight
to `use pad to get IDEA-1` after `pad auth setup`. But `pad auth setup`
only creates the first admin account — no workspace. IDEA-1 is only
seeded when a `startup`-template workspace is created (`pad init` or
`pad workspace init`).

Tightened to call out the precondition explicitly: a startup-template
workspace must exist before the trigger phrase resolves.

Spawned TASK-1143 to fix the matching CLI hint behavior — PR #403's
`printIdeaOneTriggerHint` after `pad auth setup` has the same
imprecision and should either drop the IDEA-1 mention or point users
at `pad init` first. Out of scope for this docs PR.
2026-05-04 10:32:51 -04:00
xarmian 273d75c06e docs(mcp): refresh README + SKILL.md for v0.2 surface (TASK-976) (#359)
Updates the in-repo documentation to match what shipped in PLAN-969:
- README.md's MCP section now describes the v0.2 catalog (8 tools,
  resource × action shape) instead of the retired v0.1 verb explosion.
  Documents both stability constants (CmdhelpVersion 0.1 +
  ToolSurfaceVersion 0.2) and points consumers at the structured
  error envelope contract.
- skills/pad/SKILL.md gets a one-line callout that the MCP surface is
  hand-curated and distinct from the CLI verb tree this skill drives.
  Prevents future "I added a CLI command, why isn't it in MCP?"
  confusion.
- CLAUDE.md was already updated in TASK-981; verified to match.

Companion change for getpad.dev/mcp/local lives in ../pad-web.

Parent: TASK-976 → PLAN-969.
2026-05-01 18:55:03 -04:00
xarmian 5e27989ab8 feat(attachment): add pad attachment view|show|list CLI surfaces (IDEA-898) (#321)
* feat(attachment): add `pad attachment view|show|list` CLI surfaces (IDEA-898)

Agents and CLI users had no first-class way to fetch attachment bytes
through the API: the only path to read an `![alt](pad-attachment:<uuid>)`
reference was to read the raw blob out of `~/.pad/attachments/<storage_key>`,
which bypasses workspace ACLs, doesn't work on Pad Cloud / remote /
Postgres deployments, skips the variant pipeline (TASK-872 / TASK-879 /
TASK-880), and breaks when storage moves to S3.

Three new subcommands wrap the existing REST endpoints:

- `pad attachment view <id> [-o path]` — agent-friendly: with no `-o`,
  fetches to a fresh OS temp directory using the stored filename and
  prints just the absolute path on stdout (so `$(pad attachment view <id>)`
  composes cleanly into shell pipelines). Reuses `download`'s atomic
  temp-then-rename pattern via a shared helper.

- `pad attachment show <id>` — HEAD-based metadata only; surfaces MIME,
  size, filename, ETag, Last-Modified.

- `pad attachment list [--item REF] [--category X] [--attached|--unattached]
  [--collection ID] [--sort ...] [--limit N] [--offset N]` — workspace
  list. The `--item REF` flag resolves a TASK-5-style ref to a UUID
  client-side and passes it to a new `item_id` query param on the list
  endpoint (server side: AttachmentListFilters.ItemID, ~6 lines in the
  store + 1 in the handler).

Skill update: `skills/pad/SKILL.md` gains a "Working with attachments"
subsection plus a CLI Reference entry, both ending in the hard rule that
agents must NEVER read directly from `~/.pad/attachments/`.

* style(cli): gofmt AttachmentListParams field alignment

CI's golangci-lint v2 flagged this with the gofmt formatter (configured
with simplify: true in .golangci.yml). The contiguous Sort/Limit/Offset
block at the end of the struct needs uniform column alignment — gofmt
considers the doc comment above Sort attached to that field rather than
a block separator, so the three int/string fields get aligned together.

Verified locally with `golangci-lint run --timeout=5m ./...` (v2.11.4 to
match CI) — 0 issues. Local make lint only runs `go vet ./...` and
`golangci-lint` wasn't installed, which is why this slipped through;
filing a separate follow-up to mirror the CI checks in the local
workflow.
2026-04-30 16:06:08 -04:00
xarmian 7cda0d7896 feat: rebrand to Perpetual Software + new tagline (IDEA-832) (#273)
Migrates from xarmian/pad to PerpetualSoftware/pad across the entire
repo and updates the product subtitle to "Collaborate with your AI
agents".

Go module rename
- go.mod: github.com/xarmian/pad → github.com/PerpetualSoftware/pad
- All Go imports updated across cmd/pad, internal/{cli,server,store,
  models,collections,items,events,metrics,webhooks} (~130 files)
- Test fixtures with the literal repo slug ("xarmian/pad" in JSON
  shapes, SSH/HTTPS git URL strings, workspace_context fixtures)
  also updated, including the secondary repo entry
  (xarmian/pad-web → PerpetualSoftware/pad-web — pad-web was also
  moved to the org per branch context)

Docs / config
- README badges, install instructions, brew tap, Docker image, source
  build path, sponsor link (sponsor link kept as personal @xarmian)
- Subtitle: "Project management for developers and AI agents." →
  "Collaborate with your AI agents." (README, manifests, web layout
  meta, .goreleaser homebrew description)
- CONTRIBUTING.md, SECURITY.md, skills/INSTALL.md
- .goreleaser.yaml: homebrew_casks owner, GHCR image, release github
  owner, cosign cert-identity regex, comments
- .github/workflows/release.yml: tap/release comments
- deploy/k8s/deployment.yaml: container image
- docs/deployment.md: clone URL
- web/static/{site.webmanifest,manifest.json}: description
- web/src/routes/+layout.svelte: meta description + og:description

Brew tap path is PerpetualSoftware/tap/pad (CamelCase, matches
GitHub user case). GHCR image is ghcr.io/perpetualsoftware/pad
(lowercased per GHCR's URL normalization). CODEOWNERS @xarmian and
FUNDING.yml github: xarmian intentionally retained — those are the
personal maintainer / sponsor account, separate from the org repo.

Verification: go build ./..., go test ./... (all pkgs pass), web
build, and make install all clean (TASK-844, TASK-845).
2026-04-28 12:26:39 -04:00
xarmian 4d02616d54 refactor(skill): generalize /pad for domain-agnostic workspaces (TASK-613) (#145)
* refactor(skill): generalize /pad for domain-agnostic workspaces (TASK-613)

The /pad skill is baked into the binary and runs the same regardless
of whether the workspace is a software project, a hiring pipeline, or
a research notebook. This change strips the software-centric framing
without losing any dev workflow — dev-specific rules that used to live
in SKILL.md are already shipped as conventions by the software
templates (TASK-612).

Changes
-------
- Always-on conventions blurb now frames trigger vocabulary as
  workspace-dependent and gives non-software examples (anonymize
  candidate names, always cite sources).
- "Before Performing Work" no longer hardcodes on-commit / on-pr-create
  as if they were universal. Replaced with a generic X→on-X mapping
  and a note that each template defines its own trigger set, with
  the software set called out as the common case.
- "Creating items" examples mix dev and non-dev patterns and add a
  guiding note to match intent to the workspace's actual collections.
- Planning workflow drops "Each task should be PR-sized" — replaced
  with a domain-neutral sizing guideline (software=PR, hiring=loop,
  research=question, etc.). PR-sizing for software workspaces is
  captured in the template's conventions.
- Onboarding workflow now prefers the template's onboarding playbook
  as its first step, falls back to the codebase scan only when a
  playbook isn't present or is software-flavored. Makes room for
  hiring/interviewing/research templates to ship their own
  onboarding flows without skill churn.
- Key principle #7 ("Keep it practical — tasks should be PR-sized")
  rewritten to reference the workspace's conventions.

No CLI, API, or store changes — skill file only.

Parent: PLAN-609.

* fix(skill): replace literal on-X placeholder, include playbook triggers

Per Codex review on PR #145:

- The on-X placeholder in the pre-action example commands would be
  run literally by an agent and return 0 results, causing required
  conventions to be skipped. Replaced with explicit <trigger> angle-
  bracket placeholders plus concrete examples (on-implement,
  on-commit, on-review) an agent can substitute or run directly.
- Software playbooks use a slightly different trigger vocabulary
  from software conventions (on-triage, on-release, on-review,
  on-deploy, manual). Updated the \"inspect the schema\" guidance
  to call out that agents should inspect BOTH the Conventions and
  Playbooks schemas to discover triggers, not the Conventions one
  alone.
2026-04-18 01:35:04 -04:00
xarmian bde15d45ca Rename Phases to Plans, clean up deprecated aliases (#71)
* Rename "Phases" to "Plans" and clean up deprecated phase aliases

Renames the default "Phases" collection to "Plans" across the full stack:
- DB migration renames existing collections in-place (name, slug, prefix PLAN, icon 🗺️)
- Removes all deprecated Phase* backward-compat aliases from models and store
- Removes --phase CLI flag (use --parent instead)
- Updates convention triggers: on-phase-start/complete → on-plan-start/complete
- Updates dashboard API: active_phases → active_plans, /phases-progress → /plans-progress
- Updates all frontend components, types, and documentation

Closes IDEA-124

* Fix CSRF cookie not being cleared on logout

The SessionAuth middleware was re-issuing a CSRF cookie before the
logout handler could clear it, resulting in two Set-Cookie headers.
Skip CSRF re-issue for /api/v1/auth/ paths since auth endpoints
manage their own CSRF cookies (login sets, logout clears).

* Fix migration issues found in Codex review

- P1: Move doc_type UPDATE from migration 024 into 025, which recreates
  the table with the new CHECK constraint first (SQLite enforces CHECK
  on UPDATE, so the old constraint would reject 'plan')
- P1: Add PostgreSQL migration 005 for the collection rename (phases →
  plans) — previously only existed on the SQLite path
- P2: Recreate FTS triggers, indexes, and rebuild FTS after the table
  swap in migration 025 (DROP TABLE drops associated objects in SQLite)

* Fix parent filter field name and sync .agents skill copy

Codex review round 2 findings:
- P1: Parent filter compared against `parent_id` (wrong) instead of
  `parent_link_id` — plan filtering in collection view was broken
- P1: .agents/skills/pad/SKILL.md still had old --phase flags and
  "Phases" references — synced from the updated .claude copy
- P2: Accept legacy 'phase' filter key for backward compat with
  existing saved views that serialized the old key name

* Fix PG migration JSONB casting and add slug collision guards

Codex PR review bot findings:
- P1: PostgreSQL REPLACE/LIKE don't work on JSONB columns — cast
  schema::text and fields::text before string ops, then back to ::jsonb
- P1: If a workspace already has a custom 'plans' collection, the
  rename hits UNIQUE(workspace_id, slug) — added NOT EXISTS guard
  to both SQLite and PostgreSQL migrations
2026-04-07 14:55:23 -04:00
xarmian 063ff92d00 feat: generalized parent/child items with progress tracking (#70)
* feat: generalize parent/child items — any item can have children with progress tracking

Replaces the phases-only task widget with a generalized parent/child system.
Any item (Phase, Idea, Doc, Task, etc.) can now be a parent of child items,
getting automatic progress bars, burndown charts, status grouping, drag-drop
reordering, and recursive expand/collapse up to 3 levels deep.

DB: migrate link_type 'phase' → 'parent' (migration 023)
Store: generalized methods (GetChildItems, GetItemProgress, SetParentLink
  with cycle detection), drop collection filters, per-child terminal status
API: new /items/{slug}/children and /items/{slug}/progress endpoints
Frontend: ChildItems, ChildChart, NestedChildren components replace PhaseTasks
CLI: --parent flag (--phase kept as hidden alias), list/show/changelog updated
Docs: CLAUDE.md, SKILL.md, pad-web updated for parent/child model

Full backward compatibility: old 'phase' link_type, --phase flags, phase_id/
phase_ref JSON fields all still work as deprecated aliases.

Closes PHASE-16 (9 tasks).

* fix: update collection list page to use item_id from phasesProgress response

The TS client return type changed from phase_id to item_id but the
collection list page still referenced p.phase_id, causing svelte-check
type errors in CI.

* fix: address Codex review findings — PG migration, terminal statuses, metrics, CSRF resilience

- Add PostgreSQL migration 003 to rename 'phase' links to 'parent'
- Use schema-defined terminal statuses in GetAllItemProgress instead of hardcoded defaults
- Pass computed terminal statuses from page to ChildItems component
- Only special-case 'parent' field key when not defined in collection schema
- Move MetricsMiddleware before Recoverer so panics are counted
- Always mount ChildItems for SSE subscriptions even with 0 children
- Exclude soft-deleted children from has_children enrichment query
- Re-issue CSRF cookie when session is valid but cookie is missing
- Show actual API error messages in create-item toasts
2026-04-07 09:52:31 -04:00
xarmian edd259b1b0 feat: role board — cross-collection view, dashboard breakdown, agent bindings (#60)
* feat: role board — cross-collection view, dashboard breakdown, agent bindings (#PHASE-11)

Add a standalone role board page showing all work organized by agent
role across every collection. This is the "human orchestrator" view —
see at a glance what's queued for each capability and who's working it.

Agent bindings:
- Add `tools` text field to agent_roles table (migration 019)
- CLI: `pad role create "Implementer" --tools "Claude Code + Sonnet"`
- Lightweight notes about preferred tools — no per-user binding table

Dashboard role breakdown:
- `pad project dashboard` now includes `by_role` section
- Shows item count, assigned users, and tools per role
- CLI renders role summary table with icons

Role board API:
- `GET /workspaces/{ws}/roles/board` — items from all collections grouped by role
- Filters terminal-status items (done, cancelled, etc.)
- Supports `?assigned_user_id=X` for "my work" filtering
- Returns role info, items, and assigned user list per lane

Web UI:
- New page at /{workspace}/roles with horizontal lane layout
- Collection badges on cards (items span collections)
- "My Work" toggle to filter by current user
- Empty states for no roles and empty lanes
- Sidebar nav: 🎭 Roles link added
- Responsive: stacks vertically on mobile

Skill:
- References role board in greeting and "who's working on what" patterns

* feat: add assignment picker to item detail page

Replace read-only assignment display with interactive dropdowns for
assigning users and roles directly from the item detail page.

- User dropdown populated from workspace members
- Role dropdown populated from agent roles
- Either can be set or cleared independently
- Saves immediately on change via PATCH API
- Added assigned_user_id/agent_role_id/clear_* to ItemUpdate type

* feat: add role management UI to roles page

Add a "Manage" toggle in the role board header that reveals an inline
panel for creating, editing, and deleting roles directly from the UI.

- Role cards show icon, name, description, tools, and item count
- Edit inline: name, icon, description, tools
- Create new roles with a dashed card form
- Delete with confirmation dialog
- Board auto-refreshes after changes

* refactor: replace inline role management with dialog modal

The inline horizontal card grid was cramped and hard to use. Replace
with a proper <dialog> modal that opens from the ⚙ Manage button.

- Vertical list of role rows with icon, name, description, tools, item count
- Inline edit mode per row with labeled fields
- Create new role form at the bottom with clear field labels
- Click backdrop or ✕ to close, board refreshes on close
- Native dialog handles backdrop, escape key, and focus trapping

* fix: role board mobile layout matches collection kanban, unassigned first

- Unassigned lane now appears first (before role lanes)
- Mobile: horizontal swipe with scroll-snap at 75vw columns, matching
  the collection BoardView pattern (no vertical stacking)

* feat: add drag-and-drop between role board lanes

Items can now be dragged between role lanes to reassign their role.
Uses svelte-dnd-action matching the collection BoardView pattern.

- Drag items between role lanes to change role assignment
- Drag to Unassigned lane to clear role
- Drop target highlight on hover
- Touch support with 500ms delay (same as collection board)
- Haptic feedback on mobile drag start
- Board refreshes after drop to sync server state

* fix: auto-assign user on drag to role lane, show unassigned in My Work

- When dragging an unassigned item into a role lane, automatically
  assign the current user alongside the role
- "My Work" filter now shows items assigned to you OR items with no
  user assignment, so unassigned work remains visible and claimable

* fix: three-state filter on role board — All, My Work, Unassigned

Replace the My Work toggle with a segmented button group offering
three filter modes:
- All: show everything (default)
- My Work: items explicitly assigned to the current user
- Unassigned: items with no user assignment

* fix: replace filter buttons with Highlight Mine toggle

Remove the three-state filter (All/My Work/Unassigned) and replace
with a single "Highlight Mine" toggle that dims cards not assigned
to the current user. All items remain visible and draggable — your
items just visually pop while others fade to 35% opacity (hovering
restores to 70%).

* fix: resolve undefined loadBoard and myWorkOnly in role board page

Replace 6 references to nonexistent `loadBoard()` with `loadData()`
(the actual data-loading function), and replace `myWorkOnly` with
`highlightMine` (the actual state variable). Fixes svelte-check errors
that caused CI Web Build to fail.

* fix: role breakdown pointer aliasing and terminal status filtering

P1: Copy role.ID to a local variable before taking its address in
GetRoleBreakdown, avoiding potential pointer aliasing from the range
variable (safe in Go 1.22+ but clearer with an explicit copy).

P2: Add terminal status exclusion to the GetRoleBreakdown SQL query
so dashboard counts match the board view. Previously, done/completed/
cancelled items were included in role counts, inflating active load.

Addresses Codex review comments on PR #60.
2026-04-04 07:48:33 -04:00
xarmian 9fedbe4ad6 feat: skill role awareness + role-specific conventions (#59)
* feat: skill role awareness + role-specific conventions (#PHASE-10)

Make the /pad skill role-aware so agents know what role they're acting
as and load conventions scoped to that role. Role context lives in the
conversation — no server state, no files, no new CLI commands.

Skill changes:
- Ask for role on first invocation when roles exist
- Parse "as <role>" inline: /pad as implementer, /pad what's next as reviewer
- Auto-filter work queue by active (user, role) pair
- Role-aware greeting: "Working as 🔨 Implementer. Your queue: ..."
- Load role-specific + global conventions before performing work
- Support mid-session role switching
- Updated CLI reference: --role/--assign flags, pad role commands, --comment best practice

Convention schema:
- Migration 018: add optional `role` field to Conventions collection
- Conventions with a role value apply only to that role
- Conventions without a role apply to all (backward compatible)
- Updated defaults.go with role field

* fix: use json_insert for conventions role field migration

Replace fragile REPLACE() on exact JSON string literal with SQLite's
json_insert(schema, '$.fields[#]', ...) which appends the role field
regardless of field order or custom fields in the schema. Adds a
NOT EXISTS guard via json_each() to skip if role already present.

Also adds role field to shared conventions template for non-default
workspace templates, and regression tests for schema seeding.

Addresses Codex review comment on PR #59.
2026-04-03 21:50:04 -04:00
xarmian 998716ae49 feat: unified item timeline with comment-on-update, threading, and reactions (#54)
* feat: unified item timeline with comment-on-update, threading, and reactions (IDEA-115)

Replace the separate Comments section and Version History modal on the
item detail page with a single chronological timeline that interleaves
comments, activities, and content versions.

Key changes:
- Add --comment flag to `pad item update` so agents/users can explain
  status changes inline (creates a comment linked to the activity)
- Add threaded replies (parent_id on comments) with inline reply UI
- Add emoji reactions on comments (new comment_reactions table)
- New /timeline API endpoint merges comments, activities, and versions
  server-side with dedup and collapsing of rapid edits
- New Svelte timeline components: ItemTimeline, TimelineCommentCard,
  TimelineActivityCard, TimelineVersionCard, ReactionPicker
- Remove Implementation Notes and Decision Log inputs from web UI
- Update skill docs to encourage --comment on status changes

* fix: address review findings for PR #54 (iteration 1)

- Use ListItemVersions instead of ListVersions in timeline endpoint
  so item content history renders correctly
- Add workspace validation to reply and reaction handlers to prevent
  cross-workspace comment mutation
- Raise activity cap from 500 to 10000 to avoid silently truncating
  long timelines
- Fix toggleReaction to always POST (idempotent) instead of incorrectly
  matching other users' reactions for DELETE
- Await onReply promise before clearing draft to prevent duplicate
  submissions and lost drafts on failure
- Register reaction_added/reaction_removed in SSE ITEM_EVENTS so
  reactions from other sessions appear in real-time

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: address review findings for PR #54 (iteration 2)

- Fix nil pointer dereference in timeline handler when item not found
- Store empty string instead of NULL for reaction user_id so UNIQUE
  constraint works correctly in SQLite
- Add workspace validation to handleDeleteComment (cross-workspace
  deletion was possible)
- Fix SKILL.md duplicate numbering (4. appeared twice)
- Remove || true debug artifacts from reaction conditionals
- Replace SvelteMap with plain Map in non-reactive groupReactions
- Use != null checks for timeline API params to handle offset=0
- Log warning on comment creation failure during item update

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-03 07:25:56 -04:00
xarmian 89db556e29 feat(server): add info command for TASK-134 (#52) 2026-04-02 21:44:08 -04:00
xarmian f5649b912e refactor(cli): group first-release commands for TASK-127 (#45) 2026-04-02 15:28:16 -04:00
xarmian a219f81633 fix: CLI and skill file now use issue IDs (TASK-5) instead of slugs (#15)
Agents were using verbose slugs because:
1. The skill file (SKILL.md) taught them to use `<slug>` in every example
2. CLI output showed slugs in parentheses rather than issue IDs
3. CLI usage strings said `<slug>` not `<ref>`
4. JSON output lacked a `ref` field, so agents parsing JSON only saw slugs

Changes:
- Add computed `ref` field to Item model (e.g. "TASK-5") in JSON output
- CLI create/update/delete/edit output now prominently shows issue IDs
- All CLI usage strings changed from `<slug>` to `<ref>`
- Issue IDs displayed in bold cyan (not dim) in list/show/grouped views
- Skill file rewritten to use issue IDs in all examples and instructions
- Dashboard API includes `item_ref`/`ref` in attention, suggestions, phases
- Search results now include item_number and collection_prefix for refs
- CLAUDE.md updated to document issue ID usage
2026-03-28 16:14:32 -04:00
xarmian 4708e5bacf docs: Update README, SKILL.md, CLAUDE.md, and add GitHub templates
README: Complete rewrite with value proposition, comparison table,
feature showcase, installation guide, architecture diagram, CLI reference.

SKILL.md: Add standup, changelog, watch, dependencies, and webhooks
to the agent skill so AI tools know about new capabilities.

CLAUDE.md: Document webhooks package, new API endpoints, and all new
CLI commands.

GitHub templates: Bug report and feature request issue templates (YAML),
pull request template with checklist.
2026-03-28 13:55:40 +00:00
xarmian d318ecf7fc Add workspace onboarding: CLI hints, web checklist, codebase detection, and relation field fix
- Print suggested /pad prompts after `pad init` creates a new workspace
- Add `pad onboard` command that detects project tooling (language, build system,
  test runner, CI, linter) and suggests matching conventions from the library
- Replace empty workspace welcome box with OnboardingChecklist component showing
  a 4-step guided setup with progress bar and /pad prompt hints
- Add contextual tips with /pad prompts to empty collection states
- Add onboarding workflow to /pad skill for agent-driven codebase analysis
- Fix relation fields storing slugs instead of UUIDs: server now resolves
  slugs/refs to UUIDs for relation-type fields on both create and update
2026-03-27 19:33:18 +00:00
xarmian 81579847c6 Initial release
Pad — project management for developers and AI agents.
Single Go binary with embedded SvelteKit web UI, SQLite storage,
CLI, and Claude Code /pad skill integration.

https://getpad.dev
2026-03-26 01:52:36 +00:00