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

A highly resilient, declarative dotfiles setup using Chezmoi, Mise, and Python, optimized for Linux-based devcontainers.
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 buildx bake dev-load # Build devcontainer locally
curl -fsSL https://raw.githubusercontent.com/sortakool/dotfiles/main/install.sh | bash
install.sh bootstraps mise.mise installs git, chezmoi, and uv.chezmoi init clones the repo and applies templated configs.uv run) handle complex orchestration and tool installations.DotfilesConfig centralizes 16 env vars; hk.pkl for git hooks with shared hk-common.pkl checks.dotfiles_setup).noqa, type: ignore, or pylint: disable — enforced by no_lint_skip hk step.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.All tools are declared in mise.toml and installed via mise install. Python dependencies are managed via uv with python/pyproject.toml.
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 documentationhooks/register.ts 301 lines1import 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