Files
ai-agent/scripts/conv-scaffold.sh
Gabriel Vidal e2842e27e0 refactor(complete): replace the CTA system with a complete skill
Completing a conversation is now a skill the session runs on itself before it
ends, instead of a button that resumes it afterwards. A resume is a new process,
so it re-creates the whole transcript as fresh input tokens — the same tidy-up
costs several times more once the process has exited. The spawn guidelines point
the session at `.claude/skills/complete/SKILL.md` after its final notification;
that pass waits 30s (a window to redirect after reading the result), publishes
the summary via conv-scaffold + one subagent, tidies up, and signs off with
COMPLETED.

With the prompts living in a skill, the whole CTA layer goes:

- backend: ctas.py, /api/ctas* (+ /run, /prompt, /reorder), the seeded prompt
  writer, the Cta* schemas, meta.ctas and its merge path. /api/claude-hooks
  stays — it just no longer lives in a CTA-shaped section.
- frontend: Settings -> CTAs page, the CTA buttons under a finished thread,
  the CTA badge, ctaIcons, the cta:<id> composer tags, and the ctas SSE event.
- the native Claude Code hooks list moves onto the main Settings page
  (/settings#hooks), where it is the only hooks surface left.

The COMPLETED seal now reads `meta.completedAt`, derived from the transcript's
COMPLETED marker the parser already flags, rather than the meta.ctas["complete"]
ledger — nothing has to stamp it. What survives of the old pass is unchanged:
conv-scaffold, the published summary on meta.summary, and the Summary card
(ConversationCtas -> ConversationSummary).

Existing meta.ctas data is left alone in the store; it is simply no longer read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 00:15:34 +02:00

136 lines
5.0 KiB
Bash
Executable File

#!/usr/bin/env bash
# conv-scaffold — scaffold a conversation's completion summary, and publish it.
#
# (In the homelab this is on PATH as `conv-scaffold`, via the alias shim at
# services/ai-agent/cli/conv-scaffold.sh which execs this script.)
#
# The CLI half of the `complete` skill (see backend/scaffold.py). The backend
# already parses transcripts and aggregates a conversation's commits, so the
# expensive, mechanical part of "summarise what happened here" — metadata, the
# prompts, the merged bash/tool/subagent summaries, one squashed diff across
# every commit — is generated instead of re-read out of the transcript by a
# model. What comes back is markdown with two sections left blank.
#
# conv-scaffold # scaffold THIS conversation → prints the path
# conv-scaffold --session <sid> # …a specific session
# conv-scaffold --stdout # write to stdout instead of a file
# conv-scaffold publish <file> # publish a filled-in scaffold as the summary
# conv-scaffold show # print the currently published summary
#
# Scaffolds are written to $TMPDIR/conv-scaffold-<sid>.md by default; pass
# `-o <path>` to choose. Publishing stores the markdown on the conversation's
# metadata sidecar, which is what the viewer renders under the thread — an
# untouched scaffold is refused (HTTP 422), because a report of nothing is worse
# than no report.
#
# Session resolution, in order: --session, $CLAUDE_SESSION_ID, then the homelab
# conv-meta helper (PID-ancestry match) when it is on this machine.
set -uo pipefail
AI_AGENT_URL="${AI_AGENT_URL:-http://127.0.0.1:8096}"
die() { echo "conv-scaffold: $*" >&2; exit 1; }
resolve_session() {
[[ -n "${SESSION:-}" ]] && { echo "$SESSION"; return; }
[[ -n "${CLAUDE_SESSION_ID:-}" ]] && { echo "$CLAUDE_SESSION_ID"; return; }
local helper="$HOME/homelab/.claude/skills/conv-meta/scripts/resolve-session.sh"
[[ -x "$helper" ]] && "$helper" 2>/dev/null && return
echo ""
}
# sessionId → the transcript id the API is keyed by (`<slug>/<sid>.jsonl`).
conv_id_for() {
local sid="$1"
curl -s -m 20 "$AI_AGENT_URL/api/conversations" \
| python3 -c '
import json, sys
sid = sys.argv[1]
try:
data = json.load(sys.stdin)
except ValueError:
sys.exit(1)
for c in data.get("conversations") or []:
if c.get("sessionId") == sid:
print(c.get("id") or "")
break
' "$sid"
}
cmd_scaffold() {
local sid out
sid="$(resolve_session)"
[[ -n "$sid" ]] || die "could not work out which session this is — pass --session <id>"
local cid; cid="$(conv_id_for "$sid")"
[[ -n "$cid" ]] || die "no transcript found for session $sid (has it synced yet?)"
local body code tmp
tmp="$(mktemp)"
code="$(curl -s -m 120 -o "$tmp" -w '%{http_code}' \
"$AI_AGENT_URL/api/conversations/$cid/scaffold")"
if [[ "$code" != "200" ]]; then
body="$(cat "$tmp")"; rm -f "$tmp"
die "scaffold failed (HTTP $code): ${body:0:300}"
fi
if [[ -n "${TO_STDOUT:-}" ]]; then
cat "$tmp"; rm -f "$tmp"; return
fi
out="${OUTFILE:-${TMPDIR:-/tmp}/conv-scaffold-$sid.md}"
mv "$tmp" "$out"
echo "$out"
echo "next: fill in the ## Summary and ## Analysis sections, then run" >&2
echo " conv-scaffold publish $out" >&2
}
cmd_publish() {
local file="${1:-}"
[[ -n "$file" && -f "$file" ]] || die "usage: conv-scaffold publish <file>"
local sid; sid="$(resolve_session)"
[[ -n "$sid" ]] || die "could not work out which session this is — pass --session <id>"
local cid; cid="$(conv_id_for "$sid")"
[[ -n "$cid" ]] || die "no transcript found for session $sid"
local payload tmp code
payload="$(python3 -c 'import json,sys; print(json.dumps({"markdown": open(sys.argv[1], encoding="utf-8").read()}))' "$file")"
tmp="$(mktemp)"
code="$(curl -s -m 30 -o "$tmp" -w '%{http_code}' -X PUT \
-H 'Content-Type: application/json' -d "$payload" \
"$AI_AGENT_URL/api/conversations/$cid/summary")"
if [[ "$code" != "200" ]]; then
local body; body="$(cat "$tmp")"; rm -f "$tmp"
die "publish failed (HTTP $code): ${body:0:300}"
fi
rm -f "$tmp"
echo "published — the summary now shows under the conversation"
}
cmd_show() {
local sid; sid="$(resolve_session)"
[[ -n "$sid" ]] || die "could not work out which session this is — pass --session <id>"
curl -s -m 20 "$AI_AGENT_URL/api/conversation-meta" \
| python3 -c '
import json, sys
sid = sys.argv[1]
meta = (json.load(sys.stdin) or {}).get("meta") or {}
entry = (meta.get(sid) or {}).get("summary") or {}
print(entry.get("markdown") or "(no summary published for this conversation)")
' "$sid"
}
ACTION=scaffold
ARGS=()
while [[ $# -gt 0 ]]; do
case "$1" in
publish|show) ACTION="$1"; shift ;;
--session) SESSION="${2:-}"; shift 2 ;;
-o|--out) OUTFILE="${2:-}"; shift 2 ;;
--stdout) TO_STDOUT=1; shift ;;
-h|--help) sed -n '2,27p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) ARGS+=("$1"); shift ;;
esac
done
case "$ACTION" in
scaffold) cmd_scaffold ;;
publish) cmd_publish "${ARGS[0]:-}" ;;
show) cmd_show ;;
esac