@tekmidian/pai 0.5.2 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ARCHITECTURE.md CHANGED
@@ -500,6 +500,90 @@ PAI implements six Luhmann-inspired operations on the vault's dual representatio
500
500
 
501
501
  ---
502
502
 
503
+ ## Hook System
504
+
505
+ PAI ships a comprehensive set of lifecycle hooks that integrate with Claude Code's hook events. Hooks are TypeScript source files (`src/hooks/ts/`) compiled to `.mjs` modules and deployed to `~/.claude/Hooks/`.
506
+
507
+ ### Hook Architecture
508
+
509
+ ```
510
+ Claude Code Event
511
+
512
+ ├── stdin: JSON { session_id, transcript_path, cwd, hook_event_name }
513
+
514
+ ├── Hook Process (.mjs)
515
+ │ ├── Reads stdin for context
516
+ │ ├── Performs side effects (file writes, notifications)
517
+ │ └── Writes stdout (injected as <system-reminder> into conversation)
518
+
519
+ └── stderr: diagnostic logs (visible in Claude Code's hook output)
520
+ ```
521
+
522
+ **Key constraint:** Not all hook events support stdout injection. `SessionStart` does. `PreCompact` does not. This matters for context preservation.
523
+
524
+ ### Hook Inventory
525
+
526
+ | Hook | Event | Purpose |
527
+ |------|-------|---------|
528
+ | `load-core-context.mjs` | SessionStart | Loads PAI skill system and core configuration |
529
+ | `load-project-context.mjs` | SessionStart | Detects project, loads notes dir, TODO, session note |
530
+ | `initialize-session.mjs` | SessionStart | Creates numbered session note, registers in PAI registry |
531
+ | `post-compact-inject.mjs` | SessionStart (compact) | Reads saved state and injects into post-compaction context |
532
+ | `security-validator.mjs` | PreToolUse (Bash) | Validates shell commands against security rules |
533
+ | `capture-all-events.mjs` | All events | Observability — logs every hook event to session timeline |
534
+ | `context-compression-hook.mjs` | PreCompact | Extracts session state, saves checkpoint, writes temp file for relay |
535
+ | `capture-tool-output.mjs` | PostToolUse | Records tool inputs/outputs for observability dashboard |
536
+ | `update-tab-on-action.mjs` | PostToolUse | Updates terminal tab title based on current activity |
537
+ | `sync-todo-to-md.mjs` | PostToolUse (TodoWrite) | Syncs Claude's internal TODO list to `Notes/TODO.md` |
538
+ | `cleanup-session-files.mjs` | UserPromptSubmit | Cleans up stale temp files between prompts |
539
+ | `update-tab-titles.mjs` | UserPromptSubmit | Sets terminal tab title from session context |
540
+ | `stop-hook.mjs` | Stop | Writes work items to session note, sends notification |
541
+ | `capture-session-summary.mjs` | SessionEnd | Final session summary written to session note |
542
+ | `subagent-stop-hook.mjs` | SubagentStop | Captures sub-agent completion for observability |
543
+
544
+ ### Context Preservation Relay
545
+
546
+ The most critical hook interaction is the PreCompact → SessionStart relay that preserves context across compaction:
547
+
548
+ ```
549
+ PreCompact fires (context-compression-hook.mjs)
550
+
551
+ ├── Reads transcript JSONL from stdin { transcript_path }
552
+ ├── Extracts: recent user messages, work summaries, files modified, captures
553
+ ├── Writes checkpoint to session note (persistent)
554
+ ├── Writes injection payload to /tmp/pai-compact-state-{session_id}.txt
555
+ └── Sends notification (WhatsApp or ntfy.sh)
556
+
557
+ ⬇ Claude Code runs compaction (conversation is summarized)
558
+
559
+ SessionStart(compact) fires (post-compact-inject.mjs)
560
+
561
+ ├── Reads /tmp/pai-compact-state-{session_id}.txt
562
+ ├── Outputs content to stdout → injected into post-compaction context
563
+ └── Deletes temp file (one-shot relay)
564
+ ```
565
+
566
+ **Why the relay?** PreCompact hooks cannot inject into the conversation (stdout is ignored by Claude Code for this event). SessionStart hooks can. The temp file bridges the gap.
567
+
568
+ ### Building Hooks
569
+
570
+ ```bash
571
+ bun run build # Builds everything including hooks
572
+ node scripts/build-hooks.mjs # Build hooks only
573
+ ```
574
+
575
+ The build script compiles each `.ts` hook to a self-contained `.mjs` module using `tsx` bundling, then copies them to the configured hooks directory (`~/.claude/Hooks/` by default).
576
+
577
+ ### Adding a New Hook
578
+
579
+ 1. Create the TypeScript source in the appropriate `src/hooks/ts/<event>/` directory
580
+ 2. Read stdin for `HookInput` JSON (session_id, transcript_path, cwd, hook_event_name)
581
+ 3. Use stderr for diagnostics, stdout only if the event supports injection
582
+ 4. Register the hook in `~/.claude/settings.json` under the appropriate event with the correct matcher
583
+ 5. Run `bun run build` to compile and deploy
584
+
585
+ ---
586
+
503
587
  ## Templates
504
588
 
505
589
  PAI ships three templates used during setup and customizable for your workflow.
package/README.md CHANGED
@@ -60,6 +60,44 @@ Claude finds the setup skill, checks your system, runs the interactive wizard, a
60
60
 
61
61
  ---
62
62
 
63
+ ## Context Preservation
64
+
65
+ When Claude's context window fills up, it compresses the conversation. Without PAI, everything from before that point is lost — Claude forgets what it was working on, what files it changed, and what you asked for.
66
+
67
+ PAI intercepts this compression with a two-stage relay:
68
+
69
+ 1. **Before compression** — PAI extracts session state from the conversation transcript: your recent requests, work summaries, files modified, and current task context. This gets saved to a checkpoint.
70
+
71
+ 2. **After compression** — PAI reads that checkpoint and injects it back into Claude's fresh context. Claude picks up exactly where it left off.
72
+
73
+ This happens automatically. You don't need to do anything — just keep working, and PAI handles the continuity.
74
+
75
+ ### What Gets Preserved
76
+
77
+ - Your last 3 requests (so Claude knows what you were asking)
78
+ - Work summaries and captured context
79
+ - Files modified during the session
80
+ - Current working directory and task state
81
+ - Session note checkpoints (persistent — survive even full restarts)
82
+
83
+ ### Session Lifecycle Hooks
84
+
85
+ PAI runs hooks at every stage of a Claude Code session:
86
+
87
+ | Event | What PAI Does |
88
+ |-------|--------------|
89
+ | **Session Start** | Loads project context, detects which project you're in, creates a session note |
90
+ | **User Prompt** | Cleans up temp files, updates terminal tab titles |
91
+ | **Pre-Compact** | Saves session state checkpoint, sends notification |
92
+ | **Post-Compact** | Injects preserved state back into Claude's context |
93
+ | **Tool Use** | Captures tool outputs for observability |
94
+ | **Session End** | Summarizes work done, finalizes session note |
95
+ | **Stop** | Writes work items to session note, sends notification |
96
+
97
+ All hooks are TypeScript compiled to `.mjs` modules. They run as separate processes, communicate via stdin (JSON input from Claude Code) and stdout (context injection back into the conversation).
98
+
99
+ ---
100
+
63
101
  ## Auto-Compact Context Window
64
102
 
65
103
  Claude Code can automatically compact your context window when it fills up, preventing session interruptions mid-task. PAI's statusline shows you at a glance whether auto-compact is active.
@@ -4142,9 +4142,18 @@ function mergeEnv(settings, incoming, report) {
4142
4142
  return changed;
4143
4143
  }
4144
4144
  /**
4145
+ * Strip file extension from a command basename for extension-agnostic dedup.
4146
+ * This ensures that e.g. "context-compression-hook.ts" and
4147
+ * "context-compression-hook.mjs" are treated as the same hook.
4148
+ */
4149
+ function commandStem(cmd) {
4150
+ return (cmd.split("/").pop() ?? cmd).replace(/\.(mjs|ts|js|sh)$/, "");
4151
+ }
4152
+ /**
4145
4153
  * Collect every command string already registered for a given hookType.
4146
- * Stores both the full command and the basename for flexible matching
4147
- * (handles ${PAI_DIR}/Hooks/foo.sh vs /Users/.../Hooks/foo.sh).
4154
+ * Stores full command, basename, AND extension-stripped stem for flexible
4155
+ * matching (handles ${PAI_DIR}/Hooks/foo.sh vs /Users/.../Hooks/foo.sh,
4156
+ * and .ts → .mjs migrations).
4148
4157
  */
4149
4158
  function existingCommandsForHookType(rules) {
4150
4159
  const cmds = /* @__PURE__ */ new Set();
@@ -4152,11 +4161,30 @@ function existingCommandsForHookType(rules) {
4152
4161
  cmds.add(entry.command);
4153
4162
  const base = entry.command.split("/").pop();
4154
4163
  if (base) cmds.add(base);
4164
+ cmds.add(commandStem(entry.command));
4155
4165
  }
4156
4166
  return cmds;
4157
4167
  }
4158
4168
  /**
4169
+ * Find and remove an existing rule whose command has the same stem
4170
+ * (extension-agnostic) as the incoming command. Returns true if a
4171
+ * replacement was made. This handles .ts → .mjs migrations cleanly.
4172
+ */
4173
+ function replaceStaleHook(existingRules, incomingStem, incomingCommand, incomingMatcher) {
4174
+ for (let i = 0; i < existingRules.length; i++) {
4175
+ const rule = existingRules[i];
4176
+ for (let j = 0; j < rule.hooks.length; j++) if (commandStem(rule.hooks[j].command) === incomingStem && rule.hooks[j].command !== incomingCommand) {
4177
+ rule.hooks[j].command = incomingCommand;
4178
+ if (incomingMatcher !== void 0) rule.matcher = incomingMatcher;
4179
+ return true;
4180
+ }
4181
+ }
4182
+ return false;
4183
+ }
4184
+ /**
4159
4185
  * Merge hooks — append entries, deduplicating by command string.
4186
+ * Extension-agnostic: a .mjs hook replaces an existing .ts hook with the
4187
+ * same stem, ensuring clean .ts → .mjs migrations without duplicates.
4160
4188
  */
4161
4189
  function mergeHooks(settings, incoming, report) {
4162
4190
  let changed = false;
@@ -4164,12 +4192,21 @@ function mergeHooks(settings, incoming, report) {
4164
4192
  for (const entry of incoming) {
4165
4193
  const { hookType, matcher, command } = entry;
4166
4194
  const existingRules = Array.isArray(hooksSection[hookType]) ? hooksSection[hookType] : [];
4167
- const existingCmds = existingCommandsForHookType(existingRules);
4168
4195
  const basename = command.split("/").pop() ?? command;
4196
+ const stem = commandStem(command);
4197
+ const existingCmds = existingCommandsForHookType(existingRules);
4169
4198
  if (existingCmds.has(command) || existingCmds.has(basename)) {
4170
4199
  report.push(chalk.dim(` Skipped: hook ${hookType} → ${basename} already registered`));
4171
4200
  continue;
4172
4201
  }
4202
+ if (existingCmds.has(stem)) {
4203
+ if (replaceStaleHook(existingRules, stem, command, matcher)) {
4204
+ hooksSection[hookType] = existingRules;
4205
+ report.push(chalk.yellow(` Upgraded: hook ${hookType} → ${basename} (replaced stale extension)`));
4206
+ changed = true;
4207
+ continue;
4208
+ }
4209
+ }
4173
4210
  const newRule = { hooks: [{
4174
4211
  type: "command",
4175
4212
  command
@@ -4874,6 +4911,7 @@ async function stepTsHooks(rl) {
4874
4911
  line$1();
4875
4912
  let copiedCount = 0;
4876
4913
  let skippedCount = 0;
4914
+ let cleanedCount = 0;
4877
4915
  for (const filename of allFiles) {
4878
4916
  const src = join(distHooksDir, filename);
4879
4917
  const dest = join(hooksTarget, filename);
@@ -4882,6 +4920,12 @@ async function stepTsHooks(rl) {
4882
4920
  if (srcContent === readFileSync(dest, "utf-8")) {
4883
4921
  console.log(c.dim(` Unchanged: ${filename}`));
4884
4922
  skippedCount++;
4923
+ const staleTsPath = join(hooksTarget, filename.replace(/\.mjs$/, ".ts"));
4924
+ if (existsSync(staleTsPath)) {
4925
+ unlinkSync(staleTsPath);
4926
+ console.log(c.ok(`Cleaned up stale: ${filename.replace(/\.mjs$/, ".ts")}`));
4927
+ cleanedCount++;
4928
+ }
4885
4929
  continue;
4886
4930
  }
4887
4931
  }
@@ -4889,11 +4933,22 @@ async function stepTsHooks(rl) {
4889
4933
  chmodSync(dest, 493);
4890
4934
  console.log(c.ok(`Installed: ${filename}`));
4891
4935
  copiedCount++;
4936
+ const staleTsPath = join(hooksTarget, filename.replace(/\.mjs$/, ".ts"));
4937
+ if (existsSync(staleTsPath)) {
4938
+ unlinkSync(staleTsPath);
4939
+ console.log(c.ok(`Cleaned up stale: ${filename.replace(/\.mjs$/, ".ts")}`));
4940
+ cleanedCount++;
4941
+ }
4892
4942
  }
4893
4943
  line$1();
4894
- if (copiedCount > 0) console.log(c.ok(`${copiedCount} hook(s) installed, ${skippedCount} unchanged.`));
4895
- else console.log(c.dim(` All ${skippedCount} hook(s) already up-to-date.`));
4896
- return copiedCount > 0;
4944
+ if (copiedCount > 0 || cleanedCount > 0) {
4945
+ const parts = [];
4946
+ if (copiedCount > 0) parts.push(`${copiedCount} hook(s) installed`);
4947
+ if (skippedCount > 0) parts.push(`${skippedCount} unchanged`);
4948
+ if (cleanedCount > 0) parts.push(`${cleanedCount} stale .ts file(s) cleaned up`);
4949
+ console.log(c.ok(parts.join(", ") + "."));
4950
+ } else console.log(c.dim(` All ${skippedCount} hook(s) already up-to-date.`));
4951
+ return copiedCount > 0 || cleanedCount > 0;
4897
4952
  }
4898
4953
  /**
4899
4954
  * Step 8b: Prompt for the DA (Digital Assistant) name
@@ -4952,6 +5007,11 @@ async function stepSettings(rl, daName) {
4952
5007
  hookType: "SessionStart",
4953
5008
  command: "${PAI_DIR}/Hooks/capture-all-events.mjs --event-type SessionStart"
4954
5009
  },
5010
+ {
5011
+ hookType: "SessionStart",
5012
+ matcher: "compact",
5013
+ command: "${PAI_DIR}/Hooks/post-compact-inject.mjs"
5014
+ },
4955
5015
  {
4956
5016
  hookType: "UserPromptSubmit",
4957
5017
  command: "${PAI_DIR}/Hooks/cleanup-session-files.mjs"