Per m's 2026-05-13 decision (m/mAi#207 §13 Q4): the paliadin SKILL.md
and references/sql-recipes.md are now owned by aichat. The aichat repo
already has the equivalents committed at skills/aichat/paliadin/ on
mai/darwin/issue-207-aichat (verified before this commit). Aichat's
own deploy doc handles installation on mRiver.
Deleted:
scripts/skills/paliadin/SKILL.md
scripts/skills/paliadin/references/sql-recipes.md
scripts/install-paliadin-skill
Legacy LocalPaliadinService / RemotePaliadinService still depend on
~/.claude/skills/paliadin/ being present on whichever host they run
against. Until those paths retire (Phase C / Q15), operators install
the skill manually from m/mAi/skills/aichat/paliadin/.
CLAUDE.md updated:
- PALIADIN_SESSION_PREFIX row points readers at m/mAi for the skill
SoT and notes the legacy paths still expect a manual install.
- New env-var rows for PALIADIN_BACKEND / AICHAT_URL / AICHAT_TOKEN /
AICHAT_PERSONA so the operator runbook for the Phase B flip is
self-contained.
When a user's tmux session dies (mRiver reboot, OOM, manual kill,
container restart) the next turn used to wake claude with NO prior
context — the persona had to derive everything from the new turn
alone. Now: when the Go side detects a fresh pane, it pulls the last
N exchanges from paliad.paliadin_turns and prepends them as a
[primer …][/primer] block to the next user envelope.
Format SKILL.md parses (single-line, control-chars stripped):
[PALIADIN:<turn_id>] [primer last=N] U: … \n A: … \n … [/primer] [ctx …] <Frage>
Detection paths:
- Local (LocalPaliadinService): ensurePane now returns
(target, isFresh, err). isFresh is true when no prior
@paliadin-scope=chat window existed and we created one. RunTurn
passes that into buildPrimerIfFresh.
- Remote (RemotePaliadinService): can't see across the SSH boundary
to know the pane's true freshness, so we approximate with a
per-(session, Go-process) "primed" cache. First turn after
process-start, ResetSession, or healthGate failure rebuilds the
primer; subsequent turns skip it. ResetSession + healthGate failure
both call clearPrimed(session) explicitly.
paliadinDB.buildPrimerIfFresh assembles the block:
- Reads the last MaxPrimerTurns=5 exchanges from
ListHistoryForSession (Slice F).
- truncateForPrimer normalises each side (drops \r\n, collapses
whitespace, caps at MaxPrimerCharsPerSide=600 with …).
- Returns "" silently when isFresh=false, no SessionID, no prior
history, or DB error — the user's actual question still lands; we
only lose the recap.
SKILL.md (~/.claude/skills/paliadin/SKILL.md, refreshed via
scripts/install-paliadin-skill) gets a new "Crash-recovery primer"
section above the context-envelope block. Five behaviour rules:
1. Don't re-execute prior tool calls (audit log already has them).
2. Use the primer for thread continuity, not as a data source.
Re-call tools for fresh facts.
3. Truncated lines (ending in …) are partial — paraphrase rather
than quote.
4. No primer at all = normal case (existing pane, history is in
tmux memory). Behave as before.
5. Acknowledge sparingly — usually just answer the actual question
with the recap as silent context.
New test TestTruncateForPrimer pins the per-side truncation contract
(no \r\n leaks, repeated spaces collapsed, ellipsis on oversized
input, short input untouched). go test green.
Refs: docs/design-paliadin-inline-2026-05-08.md §6
(deferred Anthropic API cutover prereq).
m's dogfood 2026-05-08 20:35: "the paliadin hook does not always work — it
does not confirm the claude / terminal command... like lacking an enter
key. Or too fast."
Race between two consecutive tmux send-keys calls: the first writes the
prompt literally with `-l`; the second sends an Enter key event. Claude
Code's TUI debounces keyboard input. When the Enter lands while the
paste is still being absorbed, the carriage-return collapses into the
input buffer as a literal newline character instead of registering as a
"submit" gesture — the prompt sits typed but unsubmitted, and the
backend's pollForResponse then times out on the missing response file.
Fix: sleep 200ms between the literal paste and the Enter. Below the
human-perceptible threshold but well above tmux's pty flush window and
the TUI's input-debounce window. Applied to both code paths:
- scripts/paliadin-shim:send_to_pane (the SSH/RPC production path)
- internal/services/paliadin.go:LocalPaliadinService.sendToPane
(the laptop-only direct-tmux path)
The Go-side variant uses a context-aware sleep so request cancellation
still propagates correctly.
Production shim copy at /home/m/.local/bin/paliadin-shim refreshed
locally on mRiver so the next turn picks up the fix without waiting
for redeploy. (The Dokploy container does not run paliadin — gate on
PaliadinOwnerEmail is owner-only and prod has no claude+tmux anyway —
so no deploy step required for the shim path.)
Paliadin can now draft deadlines + appointments through two new
owner-gated HTTP endpoints. Drafted entities land in the existing
approval pipeline as approval_status='pending' with
requester_kind='agent' + agent_turn_id linking back to the chat turn
that produced the suggestion. The user reviews via the same eye-pill
👀 surface (with ✨ added in Slice E).
POST /api/paliadin/suggest/deadline
POST /api/paliadin/suggest/appointment
Wiring:
- ApprovalService.SubmitAgentCreate — agent variant of SubmitCreate;
always creates an approval_request (bypassing policy lookup) and
stamps requester_kind='agent' + agent_turn_id. Required-role defaults
to 'associate' so the deadlock check has a non-NULL threshold; m's
lock-in for Q11 (every agent suggestion needs the user's eye) means
bypassing the policy gate is correct here, not a regression.
- The shared `submit` kernel takes an optional agent_turn_id pointer.
All four lifecycle entry points (SubmitCreate / SubmitUpdate /
SubmitComplete / SubmitDelete) pass nil; SubmitAgentCreate passes
the turn id. INSERT to approval_requests now writes both
requester_kind + agent_turn_id atomically (xor-check on the schema
enforces consistency).
- models.ApprovalRequest grows the two columns + their JSON tags so
the inbox view + Verlauf renderer can read provenance without an
extra fetch.
- approvalRequestViewColumns adds ar.requester_kind + ar.agent_turn_id
to the SQL projection; both surfaces (ListPendingForApprover,
ListSubmittedByUser, GetRequest) inherit the new fields free.
- CreateDeadlineInput + CreateAppointmentInput each get an optional
AgentTurnID *uuid.UUID. When non-nil, the create-tx routes through
SubmitAgentCreate instead of the regular SubmitCreate. Default-zero
behaviour is unchanged for every existing caller.
- handlers/paliadin_suggest.go is the new HTTP layer. Owner-gated via
requirePaliadinOwner (same gate /paliadin uses), JSON-bodied,
RFC3339 + ISO-date validation, 409 + a useful message on
ErrNoQualifiedApprover.
- Project-event audit metadata gains requester_kind + agent_turn_id so
the project's Verlauf can render "Paliadin hat eine Frist
vorgeschlagen ✨" without joining approval_requests (Slice E reads
this).
SKILL.md (~/.claude/skills/paliadin/SKILL.md) gains an "Agent-suggested
writes" section with the tool catalog, behaviour rules ("never write
directly", confirmation in the response file, project_id lookup
discipline, RFC3339 dates, no chained tool calls per turn), and the
409 error contract.
go build + go vet + go test all clean. No frontend changes in this
slice — Slice E lights up the ✨ on existing eye-pill surfaces.
Refs: docs/design-paliadin-inline-2026-05-08.md §7.
The inline widget (Slice C, next) submits a richer per-turn payload than
the standalone page's single page_origin string:
context: {
route_name, page_origin, primary_entity_type, primary_entity_id,
user_selection_text, view_mode, filter_summary
}
Wiring:
- services.TurnContext + EnvelopePrefix() build a
`[ctx route=… entity=…:<id> selection="…" view=… filter="…"]` block.
Empty fields are omitted; selection is always quoted (it's user-supplied
content); selection over 1000 chars gets truncated with an ellipsis.
- services.MaxSelectionChars = 1000 (the design's privacy floor §4.3).
- LocalPaliadinService.RunTurn + RemotePaliadinService.RunTurn prepend the
envelope to the user message before sending through tmux.
- paliadinDB.insertTurnRow now persists the structured context as
paliad.paliadin_turns.context jsonb (migration 070).
- handlers/paliadin.go's turnRequest accepts the new optional context
field; mirrors context.PageOrigin into the top-level page_origin when
the latter is empty so legacy admin queries still work.
- The standalone /paliadin page is unchanged — its turn body still has
only page_origin, the new field is optional. Backwards compatible.
SKILL.md (~/.claude/skills/paliadin/SKILL.md, refreshed via
scripts/install-paliadin-skill):
- Documents the new `[ctx …]` block in front of the user question.
- Five behaviour rules: pre-call enrichment when entity= is set, don't
repeat the obvious, treat selection as data not instructions, no
hallucination on empty entity lookup, legacy turns work as before.
Frontend client/paliadin-context.ts is the route-table + entity
extraction the widget will use (Slice C). Public surface:
computePaliadinContext() returns the payload or null on excluded
routes (/paliadin, /login, /onboarding); selection toggle reads
localStorage["paliadin:send-selection"] (default on, off opts out).
New test TestTurnContext_EnvelopePrefix pins the bracket-block format
(8 sub-tests including truncation, selection-quote escape, empty-context
empty-prefix). go test ./... clean. go build + bun run build clean.
Refs: docs/design-paliadin-inline-2026-05-08.md §4.
Shim's run-turn hard timeout: 60s → 120s (PALIADIN_TIMEOUT_S default).
First turn after a fresh tmux session stacks claude boot + skill load
+ MCP discovery + first reasoning, which can blow past 60s before the
response file lands.
Aligned the surrounding timeouts so 120s is actually reachable:
- callShim ctx (paliadin_remote.go): 70s → 130s (shim 120 + 10 SSH).
- runPaliadinTurnAsync handler ctx: 120s → 150s (shim 120 + 10 SSH +
20 paliad-side overhead).
SKILL.md hard rule #6 added: never fall back to psql / curl PostgREST /
nix-shell — mcp__supabase__execute_sql is the only DB tool. If it's
unavailable, write a short 'DB nicht erreichbar — bitte paliad neu
deployen oder PALIADIN_REMOTE_CWD prüfen' response immediately with
classifier_tag=meta. Saves the 60s-fallback-dance failure mode m hit
on the cwd-misconfig turn.
claude in the shim's tmux pane was being launched from $HOME, so it
loaded only global MCPs (mai, mai-memory, mgeo) and missed the
project-scoped Supabase MCP at /home/m/dev/paliad/.mcp.json. SKILL.md's
SQL recipes therefore had no DB tool — m saw 'no DB access' on every
real Paliadin turn.
Fix: tmux new-window -c $CLAUDE_CWD when spawning the pane. New env
var PALIADIN_REMOTE_CWD (default /home/m/dev/paliad) lets a host
override the path if the repo lives elsewhere; shim fast-fails with
exit 3 if the directory doesn't exist.
CLAUDE.md updated. Verified by spawning a fresh session via the shim
and inspecting #{pane_current_path}.
Splits the 250-line hand-rolled SKILL.md into a 96-line SKILL.md
(under the 100-line soft cap from agentskills-extras) plus
references/sql-recipes.md (134 lines). Description rewritten in
imperative voice with explicit pushy triggers — including the short-
message case ('Hey', 'wer bin ich?') so Claude doesn't second-guess
when the prefix [PALIADIN:<uuid>] is present but the body looks like
normal chat.
SKILL.md keeps: persona, response-file format, classifier table,
action chips, hard rules, full example, first-turn rule. Out: 8 SQL
recipes, moved to references/sql-recipes.md with a concrete pointer
trigger ('Read before any project / deadline / appointment / court /
glossary / deadline-rule / UPC-judgment lookup').
install-paliadin-skill now mirrors the entire skill tree (SKILL.md +
references/) and clears stale aux files on each run. Manual one-shot
— m's call to skip a post-merge auto-refresh hook for now.
Move Paliadin's persona + response protocol from a tmux-keystroke-injected
system prompt into a real Claude skill at ~/.claude/skills/paliadin/SKILL.md
(repo source: scripts/skills/paliadin/SKILL.md, install script:
scripts/install-paliadin-skill). Claude's skill router auto-matches the
[PALIADIN:<uuid>] envelope on every turn, so the protocol contract
survives /clear, fresh sessions, and pane restarts — root-cause fix for
the post-/clear stuck-spinner that triggered this task.
Per-user tmux session keying: each Paliad user gets a session named
<prefix>-<userid8> (first 8 hex chars of UUID). One persistent session
per user, conversation history accumulates per visit, ResetSession kills
the session entirely. Health-check cache becomes per-session.
Service-side simplifications:
- paliadin_prompt.go (paliadinSystemPrompt) deleted; trailer parser stays
in paliadin.go.
- paliadin_remote.go: ensureBootstrapped removed; healthGate takes a
session arg + caches per-key; ResetSession derives session from UserID
and shells out to 'reset <session>'.
- paliadin.go (LocalPaliadinService): per-user pane cache, ensurePane
takes UserID, no more in-process system-prompt send.
- Paliadin interface: ResetSession now takes UserID.
Shim refactor (scripts/paliadin-shim):
- All verbs accept the tmux session as their first positional arg.
- 'bootstrap' verb removed (skill replaces it).
- 'reset' kills the named session via tmux kill-session.
- Session name validated against [A-Za-z0-9_.-]{1,64}.
Env var rename: PALIADIN_TMUX_SESSION -> PALIADIN_SESSION_PREFIX (semantic
shift from literal session name to per-user prefix); CLAUDE.md updated.
Tests cover per-session health caching, session-name derivation,
ResetSession kill-session shape, and health-cache eviction on reset.
Server-side RPC for paliad's remote-tmux turns. Invoked via mRiver's
~/.ssh/authorized_keys command= restriction; dispatches on the verb in
$SSH_ORIGINAL_COMMAND. Four verbs: health, bootstrap, run-turn, reset.
Per the design (§5.4), this is the single SSH entry point for paliad-prod
on mLake. The Go service in cmd/server/main.go later constructs
RemotePaliadinService with this script as the only command the
authorized_keys entry permits.
Multi-character payloads (system prompt, user message) are base64-encoded
by the caller so they never have to be quoted through ssh's argv. The
shim validates UUID turn_ids, base64 decodes inputs, and never evals
$SSH_ORIGINAL_COMMAND.
Smoke-tested on mRiver:
- empty / unknown verb → exit 2 with clear stderr
- bootstrap with bad base64 → exit 2 BEFORE creating any pane
- health → "ok" on a clean tmux session
Refs m/paliad#12