Commit Graph

19 Commits

Author SHA1 Message Date
xarmian d28c28e97a docs(plugin,mcp,readme): the push monitor is consent-gated — say so where agents and operators read (PLAN-2613 S5, TASK-2620) (#1216) 2026-08-27 01:09:10 -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 5003718802 fix(push): apply delivery's visibility gate to delivered_sessions (BUG-2725) (#1187)
deliveredSessionCount applied three of watchNotificationVisible's four
gates, missing the first thing delivery checks: vis.allows(CollectionID,
ItemID). Broadcast over-reported. Targeted was worse — the publish-skip
reads this count, so the gate passed, the push went out, the stream
dropped it on visibility, and the response said delivered_sessions: 1.
An instruction lost behind a success.

Per Dave's day-49 ruling, visibility is RE-RESOLVED at push time rather
than snapshotted: membership and grants are revocable, so a value cached
at connect goes wrong exactly when revocation is what makes it matter.

The one input that cannot be re-resolved is the target connection's auth
transport — computeWatchAccessVisibility consults isBearerAuth exactly
once, inside the admin bypass, and the pushing request only knows its
own. So SessionOrigin.BearerAuth is recorded at Add(). That is NOT the
snapshot the ruling rejected: auth transport is a property of the
connection, fixed when it opened and not revocable while held, so it
cannot go stale. Armed is the precedent. SessionOrigin is kept separate
from SessionIdentity because that type documents itself as self-declared
and never verified; folding a server-derived security fact in there
would silently retract the warning for one field. Both comments state
the rule for future extenders: connection properties are admissible,
derived authorization state never is.

computeWatchAccessVisibility now takes a bool instead of an
*http.Request, which makes the per-connection input visible in the
signature and lets the count answer for a connection it is not serving.

COST: "re-resolve per counted session" reads like N access checks per
push. It is at most TWO, and sessionVisibility's memo makes that true by
construction rather than by careful calling — every other input is
per-user and identical across the sessions counted, so one varying
boolean bounds the answers at two. Pinned by a test with 50 sessions.

Codex round 1 (P1): the first version swallowed store errors into "not
visible", reintroducing BUG-2698 through this fix — a targeted push
reporting 0 SKIPS the publish, so a DB blip would drop the instruction
and answer 200, in a function whose own doc comment says why 0 is
load-bearing. Round 2 (P1): the same class one layer down —
computeWatchAccessVisibility collapsed FOUR store failures into a
denial, two discarded into underscores. Fixed as a class per CONVE-18.
Resolution and policy are now separate: stream-side callers discard the
error explicitly with reasons, only the counting caller propagates.
Round 3 CLEAN.

CONVE-23 sweep found three consumer-facing artifacts still describing
the old mechanism, none on a line this diff touched: the plugin skill
doc, the web push dialog, and pad push --help. All three corrected to
name what actually remains rather than deleting the caveat. Plugin
0.3.1 -> 0.3.2, since installed plugins are version-pinned at install.

NOT fixed, deliberately: the UNDER-count. A stream past
maxSessionsPerUser receives broadcasts while never entering the
registry. delivered_sessions remains an estimate with error in both
directions, and every consumer-facing description now says so.

Two coverage gaps recorded rather than rounded off: mutation M11
survives (the reporting test reaches only the first of four store calls,
because closing the DB fails it first), and no test drives the whole
chain store-fault-to-503 (the DB-close instrument kills the request
earlier, so such a test would have gone green against the wrong 500 —
deleted rather than relaxed).

Also lands the BUG-2752 refutation sentinel: that item claimed the OAuth
workspace allow-list went unenforced on /api/v1/events/stream. Refuted —
no allow-list-bearing credential can authenticate to /api/v1/* at all.
The test guards that format gate, so if it ever widens, the refutation's
premise fails loudly instead of silently reopening a leak.

Gates on the merged tip: make test 27 pkgs, make lint 0 issues, full
Postgres suite 27 pkgs, govulncheck, codex CLEAN, CI 7/7.

Claude-Session: https://claude.ai/code/session_01Cpr3teiHHsgcTmg2xhHA86
2026-08-24 09:45:48 -04:00
xarmian bb003dd6bb fix: five claims the final comment-truth round found (BUG-2724, BUG-2726)
The bounded process the lead set: N rounds, an author prune pass, one
final comment-truth round. This is that round's output, and the loop
stops here.

Two were mechanisms I had wrong, and both are the kind a reader would
reuse without re-deriving:

- "Different Redis DB numbers do not help" was half true. Ordinary keys
  ARE DB-scoped, so two installations on different DBs keep separate
  presence registries; it is pub/sub that ignores DBs entirely, which is
  why the buses cross-feed regardless. Stating it as "does not help" made
  the namespace look like the only fix for a problem it only half is.
- A namespace cutover's client resync was attributed to the epoch check.
  That check needs an OLD epoch to compare against and a freshly
  namespaced bus has none — the resync comes from the cold replay-buffer
  coverage check instead (knownFrom is zero, so every resume falls below
  it). Same honest outcome, different mechanism, and the mechanism is
  what someone reasoning about a cutover would use.

Three were stale or over-general after earlier changes: the admission
comment still said the global limit is passed to the bus as 0 (that
parameter is gone), `pad watch --help` and the plugin monitor description
lumped a missing .pad.toml's hourly retry in with the 5s-to-5min backoff,
and CLAUDE.md said clients must back off without the browser exception
docs/deployment.md spells out.

Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X
2026-08-22 05:21:01 +00:00
xarmian a790810bd6 docs: close the cross-artifact gaps codex round 6 found (BUG-2724, BUG-2726, BUG-2727)
The angle no earlier round probed: which artifacts a human or agent
CONSUMES should have changed and did not. Five, and the pattern is the
one my own record keeps naming — the caveat existed in the artifacts I
was editing and not in the ones that get read.

- .env.example had neither new variable and still described
  PAD_SSE_MAX_CONNECTIONS with its old single-endpoint meaning. It is the
  file an operator copies; docs/deployment.md being right does not help
  someone who never opens it.
- docs/deployment.md called the readiness endpoint /health/ready. The
  route is /api/v1/health/ready, so every instruction to go read the new
  redis block pointed at a 404. Corrected there and in four code
  comments, and the Health Check section now actually shows the three
  endpoints, the healthy payload, and the degraded one — it previously
  demonstrated only /api/v1/health, which is the build-info endpoint and
  says nothing about readiness.
- CLAUDE.md listed /api/v1/events and not /api/v1/events/stream at all,
  so the endpoint this unit bounds was undocumented in the file agents
  read first. Added, with the limits and the 429 contract.
- `pad watch --stream --help` said silence means "no workspace linked or
  padd unreachable". A capacity refusal now produces the same silence
  through the same backoff, so the help was enumerating a set that had
  quietly grown.
- The plugin skill told agents "silence means nothing changed" — now
  false in the same way, and worse, because an agent repeats it to a
  user as though the quiet were evidence. Rewritten to say what silence
  does and does not prove. The plugin monitor description had the same
  enumeration and got the same fix.

Checked rather than assumed: there are two SKILL.md files, and only the
plugin copy carries a notifications section — the embedded one has no
monitor guidance to correct.

NOT changed, and raised with the lead instead: deploy/k8s/deployment.yaml
points both probes at /api/v1/health, so the readiness endpoint is never
consumed. Fixing it is right but it changes rollout behaviour for anyone
using the shipped manifest (a database blip would start pulling pods from
the load balancer), which is a deployment-posture call rather than part
of this unit.

Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X
2026-08-22 02:45:53 +00:00
xarmian ea139272ce fix(server,watchevents): shared session presence + honest push acceptance (BUG-2698, BUG-2699) (#1175)
Two coupled defects in the push path, fixed as one unit because 2699's honest-acceptance signature is the substrate 2698's fix reports through.

BUG-2699 — Bus.Publish reports acceptance. The endpoint returned 200 pushed:true
for a publish that was dropped, because Publish returned nothing and swallowed
every failure. An error is two outcomes and they are kept apart: ErrBusClosed
proves nothing was published (503 unavailable, safe to resend), while any other
error means UNCONFIRMED — go-redis retries a command whose reply was lost, which
is why the publish script already carries a dedupe token — and gets 502
push_unconfirmed, deliberately off the web client's safe-to-resend list.
MemoryBus was the worse case, not the exempt one: neither implementation checked
`closed`, and the in-process one dropped silently with no log at all. Seven
production call sites, not the six the item named; the six best-effort producers
discard through one named helper, and an AST-based test fails when a new
producer publishes directly.

BUG-2698 — RedisSessionPresence. A session-targeted push was resolved against
the answering replica's presence registry, and the handler skips the publish
when the target is absent, so a POST landing on A for a session held on B
dropped the instruction and answered delivered_sessions:0. Fixed at the REGISTRY
rather than the gate: a shared registry makes the snapshot right, which makes
the picker complete and restores the gate's original premise, so the existing
skip becomes correct for the reason it was written. Entry and index are written
atomically under a TTL renewed by a goroutine that lives exactly as long as the
connection; a crashed process stops renewing and Redis clears it. Staleness is
unchanged and now stated in full: ~30s for a dropped client, ~90s for a dead
instance.

delivered_sessions becomes nullable — null means published-but-uncountable,
never zero — documented as three states at every consumer.

35 Codex review rounds. Notable: a per-user registry cap was added and then
removed after three consecutive rounds found defects inside it and a fourth was
asked whether it belonged in this PR at all; a context bound was documented,
disproved by its own test (go-redis does not apply a command context to
connection establishment — 5.0s measured against a 150ms ctx), and rewritten to
say what is true. Every fix was mutation-checked; one instrument was deleted for
passing on broken code and one for not asserting its own premise.

Filed rather than folded in: BUG-2724 (Redis keyspace namespacing + Cluster),
BUG-2725 (delivered_sessions is an estimate with error in both directions),
BUG-2726 (no concurrent-connection limit on the watch stream), BUG-2727 (Redis
absent from readiness/metrics; silent subscriber loss), BUG-2728 (epoch-reset
resume lead).

Gates: build · make lint 0 issues · go test ./... (25 pkgs) · svelte-check 0
errors · vitest 1738 passed · CI 7/7 including Go (PostgreSQL) and Nix.
2026-08-21 20:43:20 -04:00
xarmian 052c971785 feat(plugin): consent-gated push monitor + tri-state arm/disarm + envelope (PLAN-2613 S3, TASK-2618) (#1150)
* feat(plugin): consent-gated push monitor + tri-state arm/disarm + envelope (PLAN-2613 S3, TASK-2618)

The plugin layer of the push-consent gate. S2 built the CLI arm/disarm/status
verbs and the arm-state file; S3 makes the monitor existence itself the gate
(D1) and adds the tri-state, the envelope, and the connect ritual.

- Tri-state arm-state file: a session can be explicitly ARMED, explicitly
  DISARMED, or absent. `pad session disarm` now writes a session-scoped OFF
  marker (not a file removal), so a within-session disconnect wins even in an
  auto_arm=true repo — the disconnect verb must not be a lie there. The marker
  dies with the session (same liveness), so across sessions auto_arm remains
  the standing contract. ResolveAnnouncedArmed folds the tri-state over
  auto_arm; the monitor announces its result.

- Gated monitors (monitors.json): the single always-on monitor is replaced by
  two — an `always` auto-arm monitor and an `on-skill-invoke:connect` manual
  monitor — both running scripts/pad-monitor.sh. The wrapper gates on a new
  hidden `pad session should-arm`, dedupes concurrent monitors with a
  liveness-aware per-session lockfile, and carries the reconnect loop so an
  in-session disarm stops the stream on its next reconnect. No consent → the
  monitor exits → nothing listening.

- D5 envelope: a push notification carries the verbatim direction-with-authority
  framing (confirm in-session before anything destructive/irreversible); item-
  change kinds stay a light informational label.

- /pad:connect + /pad:disconnect skills; /pad:status gains a one-line connection
  header from `pad session status`. /pad:connect runs the workspace's
  on-session-start playbooks on the first connect only (D8), tracked by a
  Booted flag carried forward across arm/disarm. plugin 0.2.1 → 0.3.0.

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

* fix(plugin): address Codex R1 on S3 (disarm stops active stream, fail-closed local state)

- HIGH-1: a within-session disarm now stops an ACTIVE stream, not just the
  next reconnect. The monitor re-checks consent every 2s while streaming and
  cancels the connection when it flips to not-armed, then exits (D1's whole-
  stream-behind-consent gate at the top of the loop), so the plugin wrapper
  keeps it dead. Fixes /pad:disconnect being a lie for an idle SSE that might
  never naturally reconnect.
- HIGH-2: a corrupt/unreadable local arm-state file now fails CLOSED
  (LocalArmError -> not armed) instead of falling through to auto_arm, so a
  corrupted disarm marker can't silently re-arm an auto_arm repo. It is not
  reaped (reaping would re-arm on the next read); it is session-keyed and a
  re-arm overwrites it.
- Shell wrapper: an empty (mid-startup) lock pid is treated as live so two
  monitors can't both steal the lock; INT/TERM now exit (a trap otherwise
  resumes the loop and reconnects without a lock).
- Docs: plugin/skills/pad describes the new push-envelope line format;
  connect/status skills distinguish "consent set (armed)" from the server's
  observed connection counts rather than claiming "Connected".

Bounded/safe-direction residuals documented in code: the reap TOCTOU and the
Booted carry-forward race (both fail-closed / benign), and lock pid-reuse
(dedupe only, fails toward not-streaming).

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

* fix(plugin): address Codex R2 on S3 (disarm-watcher timing, semantic corruption fail-closed)

- HIGH-1: the disarm-watcher now starts BEFORE the connection is opened, so a
  disarm during connection/header negotiation cancels the request too (the
  request is built on streamCtx). streamWatchEvents also re-checks consent
  before delivering each notification and stops the stream if it was
  withdrawn, so no push is printed after a disarm even within the poll window.
- HIGH-2: a syntactically-valid but semantically-garbage arm-state file (e.g.
  {} or {"pid":1}) now fails CLOSED via a well-formedness check (StartedAt +
  PID must be present, as our writer always stamps them) before liveness or
  reaping — so it can't be judged owner-dead, reaped, and re-armed through
  auto_arm, nor mistaken for a live headless arm naming init.
- LOW: the cleanup trap uses condition 0 (portable) rather than the EXIT name.
  The disconnect skill note reflects the ~2s active-stream drop.

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

* fix(plugin): /pad:disconnect always disarms, never gated on a linked workspace (Codex R3)

Consent is session-scoped (keyed by the messaging socket, not the workspace),
so a session that connected in one repo must be able to disconnect from
anywhere — including a directory with no .pad.toml. The old precondition let a
session move to an unlinked directory, "disconnect", and keep receiving pushes.
Verified: `pad session disarm` from an unlinked cwd disarms the socket-keyed
session state; should-arm then reports not-armed back in the original repo.

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

* fix(cli): enforce the Armed != Disarmed writer invariant in arm-state validation (Codex R4)

armStateWellFormed checked only StartedAt + PID, so a well-stamped file that
violated the writer invariant — both armed and disarmed false (or both true) —
passed validation and, since SessionArmState only branches on Disarmed,
resolved to LocalArmOn and armed. The writer always sets exactly one of the
two; require it, so a neither/both file fails closed (LocalArmError).

Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V
2026-08-18 00:24:19 -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 2580c2c8bb fix(cli): gate pad init's Step-4 login on canPromptForConfig() (BUG-2592) (#1115)
* fix(cli): gate pad init's Step-4 login on canPromptForConfig() (BUG-2592)

A configured-but-unauthenticated non-interactive `pad init` fell into
doBrowserLogin and blocked on the poll wait (wall-clock-bounded since
BUG-2572, still minutes of hang nobody can complete) instead of failing
fast — Step 3 has had this exact gate since init.go:205, and
cmd_workspace.go got it in PR #1111 (BUG-2538). The gate sits AFTER the
saved-credentials check so a headless run with valid stored credentials
proceeds untouched.

Remedy text per the corrected trail ruling (the r1 constraint was
refuted by r2): piped `pad auth login --interactive` IS a working
non-interactive login (doInteractiveLogin reads a plain bufio.Reader,
piped-bytes-safe since BUG-1886), so the message points there — and
deliberately not at pad init's --email/--name/--password, which only
fire when SetupRequired.

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

* docs(plugin): pad init no longer hangs in the session-expired case — update the three claims + plugin 0.2.1

The BUG-2592 gate makes three plugin-skill passages stale (same shape
as PR #1111's codex r3 self-invalidation): capture and onboard said
`pad init` can still hang on the browser flow when configured-but-
unauthenticated, and the pad skill's whoami-guidance said the same at
its "not a safer probe" sentence. All three now state the fixed truth,
live-verified this session: fixed binary fails fast in 0.1s with the
piped-login remedy; pre-fix control binary hangs to the timeout kill in
the identical sandbox state; the remedy itself (piped `pad auth login
--interactive`) logs in and restores credentials.

Plugin 0.2.1 — text reaches nobody without a bump (version-pinned at
install).

Claude-Session: https://claude.ai/code/session_018qREYgDd6Ag1X1SDmqhyFM
2026-08-15 21:33:38 -04:00
xarmian 1882206bce docs(plugin): push-targeting etiquette + assignment-is-watch-only wording; plugin 0.2.0 (TASK-2591) (#1114)
* docs(plugin): push-targeting etiquette + assignment-is-watch-only wording; plugin 0.2.0 (TASK-2591)

PLAN-2558 S6, the plugin-visible half that TASK-2551 deferred and S5
(PR #1108) made necessary:

- monitors.json + SKILL.md no longer call assignment an addressed-to-you
  event (Phase 2 removed it from the addressed stream; assignment now
  arrives only via explicit watches) — the exact stale lines TASK-2564
  recorded from PR #1092's codex round.
- SKILL.md push etiquette covers S5 targeting: a push may be broadcast
  or targeted at one session (web composer picker / target_session_id;
  CLI always broadcasts), the notification line is identical either way,
  delivered_sessions is a pre-publish presence prediction (never a
  receipt, ~30s staleness on ungraceful drops), and pushes are never
  auto-retried — with the targeted-miss exception (delivered_sessions=0
  on a targeted push means the publish was skipped, so a resend is safe
  by construction).
- plugin.json 0.1.0 -> 0.2.0: the plugin is version-pinned at install
  (day-33, HANDO-120 delta (e)), so no text lands without the bump.
- handlers_watch_events.go: the KNOWN-STALE pointer comment now records
  the fix instead of promising it. No behavior change.

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

* docs(plugin): delivered_sessions is API-response-only — CLI reports acceptance only (codex r1 P2)

The sender-side bullet claimed the count was visible via pad push
--format json; cli.PushResult omits DeliveredSessions, so CLI JSON
cannot show it. State the truth instead: the API response carries it,
the CLI surfaces nothing about delivery. Whether the CLI should
surface it is a separate item, not a midnight scope expansion.

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

* docs(plugin): watches deliver item events, not pushes (codex r2 P3)

"cover every event on the watched item" implied a watcher sees pushes
on that item; a push is addressed dispatch (the KindPush branch returns
before the watch map) and reaches only its addressee.

Claude-Session: https://claude.ai/code/session_018qREYgDd6Ag1X1SDmqhyFM
2026-08-15 20:57:50 -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 da6ce642da feat(push): pad push — user-authored instruction dispatch to agent sessions (IDEA-2544 Phase 1) (#1090)
* feat(push): add pad push <ref> -m vertical (IDEA-2544 Phase 1)

Self-addressed, human-to-harness dispatch over the existing watch-events
bus/stream: CLI -> POST .../items/{itemSlug}/push -> a new KindPush
Notification (carrying the generalized TargetUserID addressed-to field
KindAsk will later share) -> watchNotificationVisible delivers it back
to the pushing user's own connected monitor sessions. Transient,
fire-and-forget by design (no migration, no durable inbox) since
assignment already covers the durable-notification case and this is
meant to be the explicit, no-inference dispatch verb instead.

* docs(plugin): document the push notification contract (IDEA-2544 Phase 1)

Push is the one notification kind that IS an instruction rather than a
passive fact, so it gets its own lead bullet in the plugin skill's
notification-etiquette section (ahead of the read-only/park default,
which it explicitly lifts) and a mention in the monitor's description.
The embed-source skills/pad/SKILL.md has no notification section to
mirror this into (the two files diverge by design) and is left
untouched.

* fix(push): reject over-long push messages instead of unbounded Summary

Comments truncate their notification Summary to a preview (the full
body is still fetchable), but a push message IS the payload — silently
truncating it would corrupt the instruction with nothing to recover it
from. Add maxPushMessageLen (4096, measured post-collapse) and reject
anything over it with a 400 rather than truncating; state the same
bound in `pad push --help` so it's discoverable before a 400, not only
from one.

* fix(push): close the watch-fallthrough leak, disambiguate SKILL.md exceptions

Codex round 1 P1: watchNotificationVisible's push branch only returned
early on a MATCH — a non-target caller fell through to the watch-map
check below it, so anyone holding an unconditional (or predicated)
watch on the item received every push addressed to every OTHER user,
instruction text included. Push is addressed private dispatch, not an
item-level fact watchers have a legitimate claim on (unlike assignment,
which watchers are expected to see per `pad watch --help`) — the branch
now returns unconditionally for KindPush, gating strictly on
TargetUserID and never reaching the watch-map fallback either way.
Pinned explicitly since Phase 4's session targeting is expected to
inherit this same exclusivity.

Also (codex P2): reworded the SKILL.md notification-etiquette bullets —
the new push exception and the pre-existing assignment/ask exception
literally contradicted each other ("the ONE narrow exception" claimed
singularity after push had already claimed exception status). Now
explicitly enumerated as the first and second exceptions to the
never-write rule.

* test(cli): pin that PushItem inherits X-Pad-Agent (BUG-2542 rebase)

Verified, not assumed: PushItem builds its request via c.post ->
c.newRequest like every other mutating client method (CreateWatch
included), so the attribution fix's client.agentName wiring covers it
for free with zero code changes needed on this branch. Adds a live
httptest assertion rather than trusting the code-path read alone —
the same shape as TestClientSendsResolvedAgentHeader, scoped to
PushItem specifically since that's the one method this PR added.

* fix(push): disambiguate workspace in the monitor line and skill contract

Codex round 2 P1: the watch-events stream is user-scoped ACROSS every
workspace a caller has watches in, but formatMonitorLine printed only
ItemRef/Kind/Actor/Summary and dropped the Workspace field the wire
payload already carried — a session linked to workspace A receiving a
notification for workspace B would resolve the wrong item (or 404) with
no signal in the line that anything was off.

Fixed universally, not push-only: grepped plugin/ and skills/ for
anything parsing "PAD ..." lines and found none — the Claude Code
plugin host ingests the stdout line as free-text notification prose,
formatMonitorLine's only real consumer is its own fmt.Println, so there
is no wire-format consumer a workspace prefix could break. The
ambiguity predates push (any watched item across workspaces already had
it); push just makes the consequence sharper because it carries an
instruction rather than a passive fact.

SKILL.md's push bullet now tells the agent to resolve with
`pad --workspace <workspace> item show <ref>` using the slug read off
the notification line, not a bare `pad item show <ref>`.

* fix(push): respect --format json instead of hardcoding plain text

Codex round 2 P2: pushCmd's RunE ignored the global format flag and
always printed "Pushed <ref>", silently discarding --format json.

- server.pushResponse replaces the bare map the handler wrote before —
  a typed {ref, workspace, pushed, message} shape, with workspace
  resolved to the CANONICAL slug via s.getWorkspace (not merely echoed
  from whatever the URL contained), matching the same disambiguation
  need the round-2 P1 fix addressed for the monitor line.
- cli.PushItem now returns (*PushResult, error) instead of discarding
  the response body.
- pushCmd checks formatFlag == "json" and calls cli.PrintJSON, mirroring
  runCreateWatch's existing pattern.

internal/cli/agent_identity_test.go's TestPushItemSendsResolvedAgentHeader
needed a one-line update for PushItem's new two-value return — caught by
`go vet ./...`, not `go build ./...` (which doesn't compile test files);
folding vet into my own pre-flight going forward.
2026-08-13 18:41:46 -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 d8627968d0 feat(plugin): pad Claude Code plugin — nested layout, monitors, skills (PLAN-2469 Phase 2) (TASK-2534) (#1085)
* feat(plugin): assemble Phase-0 scaffold v3 into a nested plugin layout — PLAN-2469 Phase 2 (TASK-2534)

Drops scaffold v3 (plugin.json, marketplace.json, monitors.json, the pad/
status/capture/onboard skills) in under plugin/, verbatim, rather than at
repo root. Repo root already embeds skills/pad/SKILL.md into the binary
as the surface-agnostic `pad agent install` source (embed.go); loading
the plugin flat at repo root would register BOTH that copy and the
plugin's Claude-Code-only trimmed copy as separate skills in the same
session (confirmed empirically via --plugin-dir) and would ship the
whole repo as the marketplace install payload (confirmed via a real
marketplace add + install + cache inspection). Nesting under plugin/
with marketplace.json's source: "./plugin" scopes both discovery and
the install payload to exactly the plugin's own files, with zero
content changes to the scaffold itself.

The stale minimal .claude-plugin/plugin.json at repo root is removed —
superseded by the real one at plugin/.claude-plugin/plugin.json; repo
root's .claude-plugin/ now holds only marketplace.json, which is what
`/plugin marketplace add PerpetualSoftware/pad` needs to find there.

`claude plugin validate --strict` passes clean on both the repo root
(marketplace) and plugin/ (plugin manifest) independently.

* fix(plugin): round-1 review fixes — invocation reality, degradation ordering, confirm-by-default, license (TASK-2534)

Six findings from codex round 1 + dispatcher's shallow pass:

- pad/SKILL.md's "How This Works" claimed a literal `/pad <anything>`
  command, but under plugin namespacing this skill registers as
  `pad:pad`, not a bare `/pad`. Rewrote to state the true DR-1 model
  (contextual trigger by description, not typed) and defined the
  document's own `/pad <anything>` notation as shorthand for "what the
  user said," not literal syntax. Fixes the earlier /pad:pad cosmetic
  note at its root and the two "onboarding" sections that repeated the
  same wrong dual-syntax framing.

- status/SKILL.md and capture/SKILL.md invoked `pad` before stating
  their missing-CLI fallback; reordered so the failure modes are
  handled as part of the same instruction, not an afterthought.
  monitors.json's command had no guard at all — a padless machine
  would hit a raw spawn error, not silence. Added a `command -v pad`
  shell guard (verified live, both branches: silent when pad is
  absent, invokes correctly when present) and re-verified the two
  original degradation legs (no .pad.toml, unreachable padd) through
  the exact guarded command string, not just the bare binary.

- `pad bootstrap` hard-exits ("no workspace linked...") when unlinked
  (cmd/pad/main.go's getWorkspace()), but the skill assumed it always
  succeeds and the onboarding NL routing tried to load the onboard
  PLAYBOOK before a workspace — and therefore that playbook — could
  exist. Added the unlinked-workspace branch to Context Loading and
  taught onboarding routing to run `pad workspace init` first in that
  case, matching what skills/onboard/SKILL.md already did correctly.

- capture/SKILL.md's "no ceremony" immediate-create contradicted the
  main skill's "always confirm before creating/modifying." Made
  confirm-first the default, with an explicit opt-out for workspaces
  whose conventions declare autonomous capture.

- Two `open "$IMG"` examples were macOS-only; added the Linux
  (xdg-open) alternative and a describe-the-path fallback.

- plugin.json declared MIT; the repo is Apache-2.0 (LICENSE,
  web/package.json). Corrected.

`claude plugin validate --strict` clean on both the repo root
(marketplace) and plugin/ (plugin manifest) after the fixes.

* fix(plugin): make DR-4's no-write default unmistakable (TASK-2534)

Second independent dogfood run found the etiquette text underdetermined
behavior: one run correctly parked a context nudge, another interpreted
"fold it in" as license to run pad item comments and post a real reply
to the watched item — a write action from a context notification, which
DR-4 never intended and the plugin's noise-discipline promise depends on
never happening. Rewrote the section: read-only one-line park-and-summarize
is now the explicit default for every watch notification and any addressed-
to-you event the model isn't certain about; writing to Pad in reaction to a
notification is called out as prohibited by name (no more "fold it in"
euphemism), with a single narrow exception (an assignment/ask explicitly
addressed to the session's user, where acting is unambiguously expected)
and an explicit "when in doubt, park" tiebreaker.

claude plugin validate --strict clean on both repo root (marketplace) and
plugin/ (plugin manifest).

* fix(plugin): loop the pad-missing monitor guard instead of a one-shot sleep (TASK-2534)

Verifying the finding-2 guard through Claude Code's REAL monitor spawn
path (--bg --plugin-dir, not a manual `sh -c` test) surfaced a real gap
the manual test couldn't: the runner does NOT relaunch a monitor command
after it exits. The round-1 guard (`command -v pad && exec ... || sleep
3600`) is a single check-then-sleep-then-exit — once that one hour
elapses, the process exits and, per this observed behavior, is never
restarted, leaving the session permanently monitor-less for the rest of
its life on a machine where pad wasn't yet on PATH at session start.
That silently breaks DOC-2479's "sleep-retry hourly" contract, which the
Go binary's own `pad watch --stream --for-session` loop honors correctly
for its two conditions (no .pad.toml, padd unreachable) via an actual
internal retry loop — this shell guard needs the same shape for its one
condition (pad binary absent).

Wrapped the check in `while ! command -v pad; do sleep 3600; done` so it
keeps re-checking indefinitely instead of dying after one interval, then
falls through to the same `exec pad watch --stream --for-session` once
pad becomes resolvable. Verified live through the real spawn path with a
shortened sleep interval on a throwaway plugin copy: a fresh sleep child
appears every cycle (confirms the loop keeps running, not one-shot), then
re-verified the actual committed command (real sleep 3600) resolves a
live `pad watch --stream --for-session` process when pad is present and
sits in a healthy, silent, no-error `sleep 3600` loop when it's absent —
both through claude --bg --plugin-dir, not manual shell invocation.

claude plugin validate --strict clean on both repo root (marketplace)
and plugin/ (plugin manifest).

* fix(plugin): probe watch capability, not just binary existence, in the monitor guard (TASK-2534)

Codex round 2, P1: the round-1 fix looped on `command -v pad`, but an
old pad binary predating `pad watch` resolves on PATH fine and then
dies non-zero the moment `exec pad watch --stream --for-session` runs
— and the previous commit already established the runner never
relaunches an exited monitor command, so that death is permanent for
the session. `command -v` only proves the binary exists, not that it
has the subcommand this guard needs.

Changed the loop condition to `pad watch --help`, which probes the
actual capability in one call: covers both missing-binary (no such
command) and old-binary-without-watch (unknown subcommand, non-zero
exit) with a single condition. Verified first that `--help` is safe to
call as a probe — it succeeds (exit 0) with no `.pad.toml` anywhere
and padd unreachable, so it can't deadlock a legitimate unlinked-
project or padd-down user behind the guard.

claude plugin validate --strict clean on both repo root (marketplace)
and plugin/ (plugin manifest).

* fix(plugin): round-2 doc fixes — capture routing, confirm scope, invocation wording, CLI accuracy (TASK-2534)

Findings 2-8 from codex round 2:

- capture/SKILL.md: no unlinked-workspace branch — would fail opaquely
  on `pad collection list` without .pad.toml. Mirrors the same routing
  fix the main skill already has: run `pad workspace init` first.

- pad/SKILL.md's notification write-exception didn't say whether the
  confirm-always principle (Key Principles #3) still applies inside it.
  It does — the exception only lifts never-write, not confirm-first —
  now stated explicitly where the exception lives, same rule as
  capture's autonomous-capture opt-out.

- The `/pad` shorthand disclaimer scoped itself to "the playbook-routing
  and examples sections below," missing Context Loading's "on every
  `/pad invocation`" a few lines above it. Widened to cover the whole
  document. plugin.json's own description repeated the bare "/pad
  conversational surface" claim; reworded to name the real typed
  shortcuts instead, and synced marketplace.json's plugin-entry
  description to match (was already a paraphrase, now identical).

- Ideation example passed both `--content` and `--stdin` to the same
  `pad item create` call — verified --stdin wins and hangs on ReadAll
  with no piped input. Dropped --stdin, kept --content.

- Plan-retro example was missing the mandatory `--comment` this same
  doc teaches elsewhere. Key Principles #2's status enumeration listed
  "blocked" as a task status — verified against
  internal/collections/templates.go: the tasks schema's actual options
  are open/in-progress/done/cancelled, no "blocked". Fixed both.

- Two role-board pointers claimed `pad server open` lands on
  `/{workspace}/roles` directly; verified cmd/pad/cmd_server.go's open
  command only appends the workspace slug, never a sub-path. Reworded
  both to describe navigating to Roles from the opened UI rather than
  asserting an unverified deep-link.

claude plugin validate --strict clean on both repo root (marketplace)
and plugin/ (plugin manifest).

* fix(plugin): safe (non-hanging) unlinked-workspace self-heal, onboarding offer in status, ask-events wording (TASK-2534)

Codex round 3, findings 1-3:

- The routing added in rounds 1-2 told the skill to run bare
  `pad workspace init` whenever bootstrap reported "no workspace
  linked." Verified live this can HANG INDEFINITELY: with pad
  configured (mode/URL set) but no admin account created yet /
  session not authenticated, `pad workspace init` calls a
  browser-based setup/login flow with no TTY guard and no
  non-interactive fallback — unlike `pad init`, which has an explicit
  `!canPromptForConfig()` check at the same step and fails fast with a
  headless-bootstrap hint instead. Bootstrap's own two failure
  signatures ("Pad is not configured" vs "no workspace linked") don't
  fully disambiguate this either — verified live that "no workspace
  linked" fires even when the deeper problem is "no admin account
  exists yet," which is exactly the state that hangs.

  Fixed by adding a `pad auth whoami` preflight (verified live: fast,
  safe, never blocks, in all three states — unconfigured, configured-
  but-unauthenticated, and fully set up) before ever attempting
  `pad workspace init`. Only self-heal when whoami reports a real
  user; otherwise tell the user to run `pad init` themselves in an
  interactive terminal (Claude Code: suggest `! pad init`). Applied
  consistently across pad/SKILL.md's Context Loading and Onboarding
  routing, onboard/SKILL.md, and capture/SKILL.md's unlinked-workspace
  branch (round 2 addition, same hang risk).

- status/SKILL.md ran `pad project dashboard --format json` but never
  checked its `needs_onboarding` field (verified present on that
  response too, not just bootstrap's — internal/server/handlers_
  dashboard.go's NeedsOnboarding), so the mandated onboarding offer
  couldn't fire via `/pad:status`. Added the same offer wording the
  main skill uses.

- monitors.json's description claimed the stream delivers "asks for
  your role" alongside assignment; verified against
  internal/watchevents/watchevents.go: KindAsk is contract-reserved
  with no Phase 1 producer. Reworded to describe what ships today,
  noting ask-events as reserved-not-yet-emitted.

Filed docapp TASK-2537 for the fourth finding (embed-source
skills/pad/SKILL.md has the same --content/--stdin bug and has
drifted from these corrections) — selective port, out of this diff's
scope per the two skills' by-design framing divergence.

claude plugin validate --strict clean on both repo root (marketplace)
and plugin/ (plugin manifest).

* fix(plugin): --full on body-promising queries, capture loads always-on conventions, onboard's already-set-up path (TASK-2534)

Codex round 4, findings 1-3:

- Every `pad item list conventions|playbooks --format json` example whose
  surrounding prose promises "bodies" was missing `--full` — verified
  against cmd/pad/cmd_item.go: the flag exists specifically because JSON
  output defaults to a token-light summary shape with no content.
  Without it, all 8 of these calls (the Context Loading bootstrap-field
  description at :41, the 7 trigger-query examples under "Before
  Performing Work") would return exactly what the surrounding text says
  they're loading bodies to avoid: metadata with no content. Added
  --full to all 8, not just the block the finding cited — the "Template"
  lines carry the identical "pull their bodies" promise as the
  "Concrete examples" lines a few lines below them, so leaving one set
  fixed and the other not would just move the inconsistency.

  Swept the rest of the file's `item list ... --format json` examples
  (Ideation load-context, Status Check role queue, Daily Standup) —
  left those alone; their surrounding prose asks for enumeration/counts
  ("3 items in your queue", "Yesterday/Today/Blockers"), not body
  content. The one exception found: Retrospective's task-load line
  promises "what shipped, what was deferred, lessons learned" — that
  needs actual task content to synthesize, so it got --full too, with
  a one-line note explaining why (the other fixed lines didn't need
  one; their promise was already explicit in the surrounding prose).

- capture/SKILL.md never loaded conventions at all. Per the dispatcher's
  ruling: low ceremony means skipping bootstrap's dashboard/playbook/
  role weight, not skipping mandatory project rules (trigger=always
  conventions exist precisely to be small and always-applied). Capture
  now runs the always-on conventions query (with --full) before
  creating and applies whatever it returns; the autonomous-capture
  exception now checks the SAME load instead of running its own
  separate (previously --full-less) query. One sentence documents the
  tradeoff as deliberate: always-on rules yes, full bootstrap no.

- onboard/SKILL.md only defined the needs_onboarding=true branch. Added
  the false branch: say the workspace is already set up, summarize what
  exists (collection/convention counts from the bootstrap payload
  already in hand), and offer extend/audit instead of re-running
  first-time setup — same confirm-first rule as everywhere else.

claude plugin validate --strict clean on both repo root (marketplace)
and plugin/ (plugin manifest).
2026-08-12 23:49:47 -04:00