Commit Graph

10 Commits

Author SHA1 Message Date
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