SLOPSHOPPER

session-start

On each new interactive session: reload skills and plugins, then name the session <project>-<Chicago ISO ns>.<feature>

newtoaststatuspromptmodelprocess
★ 4v1.0.0no licenseupdated 2026-10-03ray-manaloto/dotfiles/.claude/skills/session-start
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-start
› fix the failing auth test and add an audit log call ╭───────────────────────────────────────╮ │ session-start │ ⏺ Read(src/auth.ts) │ session-start: decide output not JSON │ ⎿ Read 6 lines ╰───────────────────────────────────────╯ ⏺ Update(src/auth.ts) ╭────────────────────────────────────────────╮ ⎿ Added 2 lines, removed 1 line │ session-start │ ⏺ Bash(bun test) │ session-start: pending rename failed: │ ⎿ 3 pass, 1 fail │ pending output not JSON │ ╰────────────────────────────────────────────╯ ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ session-start: session-start ERROR: pending rename failed: pending output not JSON
README

Reproducible Dotfiles (AMD64)

A highly resilient, declarative dotfiles setup using Chezmoi, Mise, and Python, optimized for Linux-based devcontainers.

Quick Start

Local Development

mise install                                       # Install all tools
mise run lint                                      # Run lint checks (read-only ≡ CI; `mise run fmt` auto-fixes)
uv run --project python pytest tests/ -x -q      # Run all 190 tests

Docker Build

docker buildx bake dev-load                        # Build devcontainer locally

Bootstrap (Container)

curl -fsSL https://raw.githubusercontent.com/sortakool/dotfiles/main/install.sh | bash

Architecture

  1. Stage 0: install.sh bootstraps mise.
  2. Stage 1: mise installs git, chezmoi, and uv.
  3. Stage 2: chezmoi init clones the repo and applies templated configs.
  4. Stage 3: Python lifecycle hooks (uv run) handle complex orchestration and tool installations.

Features

  • Strictly AMD64: Forced x86_64 architecture for container consistency.
  • Declarative Config: Pydantic DotfilesConfig centralizes 16 env vars; hk.pkl for git hooks with shared hk-common.pkl checks.
  • Zero-Bash: Logic is encapsulated in a typed, linted Python library (dotfiles_setup).
  • Zero Lint Suppressions: No noqa, type: ignore, or pylint: disable — enforced by no_lint_skip hk step.
  • Environment Auditor: Built-in health checks for identity, toolchains, and SSH connectivity.
  • CI/CD: GitHub Actions — lint → contract-preflight → a changes path-gate → base-prep → p2996-prep → build → smoke-test (smoke + Dive); promote retags on main; benchmark + Trivy run async in image-analysis.yml. Docs-only changes skip the build chain.

Tool Management

All tools are declared in mise.toml and installed via mise install. Python dependencies are managed via uv with python/pyproject.toml.

Local Testing

uv run --project python pytest tests/ -x -q      # All tests
uv run --project python dotfiles-setup verify run # Contract verification
mise run pin-actions                                # Verify GHA SHA-pinning
mise run lint-docs                                  # Validate agent documentation
Source 1 files
hooks/register.ts 301 lines
1import type { EngineInterface, Register } from "claude-code";
2
3/**
4 * The all-session `session-start` mod (spec
5 * `docs/specs/coordinator-auto-handoff-2026-10-02.md` §8; requirements 23).
6 *
7 * On `session.start` in an interactive session (bg included; `-p` and the SDK
8 * skip), asks python once per session id (`dotfiles-setup session-start
9 * decide`), queues `/reload-skills` then `/reload-plugins --force` — bg spares
10 * can predate the claim, so their skill and plugin state may be stale — and
11 * applies the naming convention `<project>-<Chicago ISO ns>.<feature>`:
12 * Helpers are deliberately local: each skills-dir plugin loads independently.
13 *
14 * - `keep`: the `-n` name already conforms;
15 * - `rename`: `/rename <name>` with the branch slug as the feature;
16 * - `defer`: on the default branch, rename at the FIRST prompt with a short
17 *   model-made slug appended to python's prefix (fallback `session`);
18 * - `nonconforming`: a `-n` name outside the convention is NEVER renamed —
19 *   lanes are addressed by it — only a toast and a status.
20 *
21 * Python writes its state BEFORE answering, so the session.start a reload may
22 * re-fire answers `already-ran` (carrying a still-pending defer prefix, since a
23 * reload discards this module's memory). Function-hook failures are skipped
24 * silently, so the status line is the heartbeat: `session-start ok…` or
25 * `session-start ERROR: <reason>`. Every failure is a value, never a throw.
26 */
27
28const DECIDE_TIMEOUT_MS = 60_000;
29const SLUG_MODEL = "haiku";
30const SLUG_FALLBACK = "session";
31const SLUG_MAX_WORDS = 5;
32const SLUG_MAX_CHARS = 60;
33const PROMPT_MAX_CHARS = 2_000;
34const REASON_MAX_CHARS = 80;
35const PENDING_READ_MAX_ATTEMPTS = 3;
36
37/** Mirrors `StartDecision.to_json()` in `python/src/dotfiles_setup/session_start.py`. */
38const ACTIONS = [
39  "keep", "rename", "defer", "nonconforming", "unknown", "already-ran",
40  "non-interactive", "invalid-session-id", "state-write-failed", "state-locked",
41] as const; // Mirrors Python StartAction (Literal).
42type StartAction = (typeof ACTIONS)[number];
43type StartDecision = {
44  action: StartAction;
45  reload: boolean;
46  name: string | null;
47  prefix: string | null;
48};
49
50/** A deferred rename waiting for the first prompt of this module's lifetime. */
51type PendingName = { sessionId: string; prefix: string | null; name: string | null };
52const pending = new Map<string, PendingName>();
53const pendingChecked = new Set<string>();
54const pendingAttempts = new Map<string, number>();
55const renaming = new Set<string>();
56
57const toasted = new Set<string>();
58
59function toastOnce($: EngineInterface, text: string): void {
60  if (toasted.has(text)) return;
61  toasted.add(text);
62  $.ui.toast(text);
63}
64
65function fail($: EngineInterface, reason: string): void {
66  const short = reason.length > REASON_MAX_CHARS ? `${reason.slice(0, REASON_MAX_CHARS)}…` : reason;
67  try {
68    $.ui.status(`session-start ERROR: ${short}`);
69    toastOnce($, `session-start: ${short}`);
70  } catch {
71    // Nothing left to report through.
72  }
73}
74
75function errorText(error: unknown): string {
76  return error instanceof Error ? error.message : String(error);
77}
78
79/** Queue a slash command; never awaited, its rejection made visible. */
80function queue($: EngineInterface, command: string, args?: string): void {
81  void $.command
82    .run(args === undefined ? { command } : { command, args })
83    .catch((error: unknown) => fail($, `/${command} rejected: ${errorText(error)}`));
84}
85
86function parseStart(stdout: string): StartDecision | null {
87  let value: unknown;
88  try {
89    value = JSON.parse(stdout);
90  } catch {
91    return null;
92  }
93  if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
94  const { action, reload, name, prefix } = value as Record<string, unknown>;
95  if (typeof action !== "string" || typeof reload !== "boolean") return null;
96  if (!ACTIONS.some((candidate) => candidate === action)) return null;
97  if (name !== null && typeof name !== "string") return null;
98  if (prefix !== null && typeof prefix !== "string") return null;
99  return { action: action as StartAction, reload, name, prefix };
100}
101
102async function python(
103  $: EngineInterface,
104  args: readonly string[],
105): Promise<{ exitCode: number; stdout: string }> {
106  const projectDir = (await $.env.get("CLAUDE_PROJECT_DIR")) ?? (await $.session.root());
107  return $.process.run(
108    ["uv", "run", "--project", "python", "dotfiles-setup", "session-start", ...args],
109    { cwd: projectDir, timeoutMs: DECIDE_TIMEOUT_MS },
110  );
111}
112
113async function decide(
114  $: EngineInterface,
115  sessionId: string,
116  cwd: string,
117): Promise<StartDecision | string> {
118  let run: { exitCode: number; stdout: string };
119  try {
120    run = await python($, ["decide", "--session-id", sessionId, "--cwd", cwd]);
121  } catch {
122    return "decide failed to run";
123  }
124  if (run.exitCode !== 0) return `decide rc ${run.exitCode}`;
125  return parseStart(run.stdout) ?? "decide output not JSON";
126}
127
128/** At most five lowercase kebab words; empty when nothing usable came back. */
129function toSlug(text: string): string {
130  return text
131    .toLowerCase()
132    .replace(/[^a-z0-9]+/g, " ")
133    .trim()
134    .split(/\s+/)
135    .filter((word) => word !== "")
136    .slice(0, SLUG_MAX_WORDS)
137    .join("-")
138    .slice(0, SLUG_MAX_CHARS)
139    .replace(/-+$/, "");
140}
141
142/** Compatibility boundary: vendored 2.1.277 says string; 2.1.288 returns an object.
143 * Coordinator must regenerate vendor types separately; do not patch declarations.
144 */
145function completionText(reply: string | { isAnswered: boolean; text: string }): string | undefined {
146  return typeof reply === "string" ? reply : (reply.isAnswered ? reply.text : undefined);
147}
148
149async function confirmRename($: EngineInterface, sessionId: string, name: string): Promise<void> {
150  // Only called from an unawaited task: the command runs once the session is idle.
151  await $.command.run({ command: "rename", args: name });
152  const marked = await python($, ["renamed", "--session-id", sessionId, "--name", name]);
153  if (marked.exitCode !== 0) throw new Error(`renamed rc ${marked.exitCode}`);
154  pending.delete(sessionId);
155  $.ui.status("session-start ok (renamed)");
156}
157
158async function renameFromPrompt(
159  $: EngineInterface,
160  claim: PendingName,
161  text: string,
162): Promise<void> {
163  let slug = SLUG_FALLBACK;
164  try {
165    const reply = await $.model.complete({
166      model: SLUG_MODEL,
167      prompt:
168        "Name the task below in at most five lowercase words. Reply with the words only.\n\n" +
169        text.slice(0, PROMPT_MAX_CHARS),
170      maxTokens: 30,
171    });
172    slug = toSlug(completionText(reply) ?? "") || SLUG_FALLBACK;
173  } catch {
174    slug = SLUG_FALLBACK;
175  }
176  await confirmRename($, claim.sessionId, claim.name ?? `${claim.prefix}.${slug}`);
177}
178
179function scheduleRename($: EngineInterface, claim: PendingName, text?: string): void {
180  if (renaming.has(claim.sessionId)) return;
181  renaming.add(claim.sessionId);
182  pending.set(claim.sessionId, claim);
183  const work = claim.name === null
184    ? renameFromPrompt($, claim, text ?? "")
185    : confirmRename($, claim.sessionId, claim.name);
186  void work.catch((error: unknown) => {
187    // Keep the claim and Python prefix: a rejected rename is still pending.
188    fail($, `rename failed: ${errorText(error)}`);
189  }).finally(() => renaming.delete(claim.sessionId));
190}
191
192async function recoverPending($: EngineInterface, sessionId: string): Promise<void> {
193  if (pendingChecked.has(sessionId)) return;
194  const attempts = pendingAttempts.get(sessionId) ?? 0;
195  if (attempts >= PENDING_READ_MAX_ATTEMPTS) return;
196  pendingAttempts.set(sessionId, attempts + 1);
197  try {
198    const run = await python($, ["pending", "--session-id", sessionId]);
199    if (run.exitCode !== 0) throw new Error(`pending rc ${run.exitCode}`);
200    const decision = parseStart(run.stdout);
201    if (decision === null) throw new Error("pending output not JSON");
202    if (decision.action === "rename" && decision.name !== null) {
203      pending.set(sessionId, { sessionId, name: decision.name, prefix: null });
204    } else if (decision.prefix !== null) {
205      pending.set(sessionId, { sessionId, prefix: decision.prefix, name: null });
206    } else if (["invalid-session-id", "state-write-failed", "state-locked"].includes(decision.action)) {
207      throw new Error(decision.action);
208    }
209    // Only successful recovery is cached; failures get at most three reads.
210    pendingChecked.add(sessionId);
211  } catch (error: unknown) {
212    if (attempts + 1 >= PENDING_READ_MAX_ATTEMPTS) {
213      throw new Error("pending recovery failed after 3 attempts; reload to retry");
214    }
215    throw error;
216  }
217}
218
219async function start($: EngineInterface, cwd: string): Promise<void> {
220  const sessionId = await $.session.id();
221  const decision = await decide($, sessionId, cwd);
222  if (typeof decision === "string") {
223    fail($, decision);
224    return;
225  }
226  // A repeat with no prefix can still have an unconfirmed branch rename.
227  if (["keep", "rename", "defer", "nonconforming", "unknown"].includes(decision.action) || decision.prefix !== null) {
228    pendingChecked.add(sessionId);
229  }
230  applyStart($, sessionId, decision);
231  if (decision.reload) {
232    queue($, "reload-skills");
233    queue($, "reload-plugins", "--force");
234  }
235}
236
237function applyStart($: EngineInterface, sessionId: string, decision: StartDecision): void {
238  switch (decision.action) {
239    case "keep":
240      $.ui.status("session-start ok");
241      return;
242    case "rename":
243      if (decision.name === null) {
244        fail($, "rename without a name");
245        return;
246      }
247      $.ui.status("session-start pending rename");
248      scheduleRename($, { sessionId, name: decision.name, prefix: null });
249      return;
250    case "defer":
251    case "already-ran":
252      if (decision.prefix !== null) {
253        pending.set(sessionId, { sessionId, prefix: decision.prefix, name: null });
254        $.ui.status("session-start ok (name at first prompt)");
255      } else if (decision.action === "defer") {
256        fail($, "defer without a prefix");
257      } else {
258        $.ui.status("session-start ok");
259      }
260      return;
261    case "nonconforming":
262      $.ui.status("session-start: name not in convention");
263      toastOnce(
264        $,
265        `session-start: "${decision.name ?? "?"}" is outside <project>-<yyyyMMdd'T'HHmmss.ns±HH>.<feature>; left as is`,
266      );
267      return;
268    case "unknown":
269      $.ui.status("session-start: name unknown");
270      return;
271    default:
272      fail($, decision.action);
273  }
274}
275
276export const register: Register = (on) => {
277  on("session.start", async ($, e, next) => {
278    const result = await next(e);
279    try {
280      if (!e.isInteractive) return result;
281      await start($, e.cwd);
282    } catch (error: unknown) {
283      fail($, `hook failed: ${errorText(error)}`);
284    }
285    return result;
286  });
287
288  on("prompt.submit", async ($, e, next) => {
289    const result = await next(e);
290    try {
291      const sessionId = await $.session.id();
292      await recoverPending($, sessionId);
293      const claim = pending.get(sessionId);
294      if (claim !== undefined) scheduleRename($, claim, e.text);
295    } catch (error: unknown) {
296      fail($, `pending rename failed: ${errorText(error)}`);
297    }
298    return result;
299  });
300};
301