portable-agent-layer 0.35.0 → 0.37.0

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.
Files changed (108) hide show
  1. package/README.md +2 -1
  2. package/assets/skills/analyze-pdf/tools/pdf-download.ts +1 -1
  3. package/assets/skills/analyze-youtube/tools/youtube-analyze.ts +1 -1
  4. package/assets/skills/consulting-report/tools/dev.ts +2 -2
  5. package/assets/skills/consulting-report/tools/generate-pdf.ts +9 -9
  6. package/assets/skills/consulting-report/tools/scaffold.ts +2 -2
  7. package/assets/skills/create-pdf/tools/md-to-html-pdf.ts +2 -2
  8. package/assets/skills/opinion/tools/opinion.ts +3 -2
  9. package/assets/skills/presentation/SKILL.md +1 -1
  10. package/assets/skills/presentation/tools/doctor.ts +2 -5
  11. package/assets/skills/presentation/tools/lib/inline.ts +6 -11
  12. package/assets/skills/presentation/tools/lib/lint-helpers.ts +2 -2
  13. package/assets/skills/presentation/tools/lib/lint-rules.ts +5 -2
  14. package/assets/skills/presentation/tools/setup-template.ts +10 -7
  15. package/assets/skills/projects/SKILL.md +44 -21
  16. package/assets/skills/research/tools/gemini-search.ts +2 -2
  17. package/assets/skills/research/tools/grok-search.ts +2 -2
  18. package/assets/skills/research/tools/perplexity-search.ts +2 -2
  19. package/assets/skills/telos/SKILL.md +7 -52
  20. package/assets/skills/telos/tools/update-telos.ts +0 -1
  21. package/assets/templates/PAL/ALGORITHM.md +54 -5
  22. package/assets/templates/PAL/PROJECT_LIFECYCLE.md +48 -0
  23. package/assets/templates/PAL/README.md +1 -1
  24. package/assets/templates/PAL/STEERING_RULES.md +4 -0
  25. package/assets/templates/PAL/SYSTEM_ARCHITECTURE.md +32 -17
  26. package/assets/templates/PAL/WORK_TRACKING.md +1 -1
  27. package/assets/templates/hooks.codex.json +44 -0
  28. package/assets/templates/hooks.cursor.json +11 -5
  29. package/assets/templates/pal-settings.json +1 -3
  30. package/assets/templates/settings.claude.json +2 -1
  31. package/package.json +2 -1
  32. package/src/cli/index.ts +112 -14
  33. package/src/cli/migrate.ts +299 -0
  34. package/src/cli/setup-identity.ts +3 -3
  35. package/src/cli/setup-telos.ts +12 -80
  36. package/src/hooks/CompactRecover.ts +11 -5
  37. package/src/hooks/LoadContext.ts +35 -11
  38. package/src/hooks/PreCompactPersist.ts +26 -34
  39. package/src/hooks/SecurityValidator.ts +43 -21
  40. package/src/hooks/StopOrchestrator.ts +4 -1
  41. package/src/hooks/UserPromptOrchestrator.ts +4 -2
  42. package/src/hooks/handlers/auto-graduate.ts +2 -2
  43. package/src/hooks/handlers/backup.ts +3 -3
  44. package/src/hooks/handlers/context-digests.ts +74 -0
  45. package/src/hooks/handlers/failure.ts +5 -3
  46. package/src/hooks/handlers/inject-retrieval.ts +29 -6
  47. package/src/hooks/handlers/persist-last-exchange.ts +76 -0
  48. package/src/hooks/handlers/rating.ts +2 -1
  49. package/src/hooks/handlers/readme-sync.ts +3 -2
  50. package/src/hooks/handlers/session-intelligence.ts +17 -93
  51. package/src/hooks/handlers/session-name.ts +2 -2
  52. package/src/hooks/handlers/synthesis.ts +5 -2
  53. package/src/hooks/handlers/update-counts.ts +3 -2
  54. package/src/hooks/lib/agent.ts +20 -18
  55. package/src/hooks/lib/claude-md.ts +69 -14
  56. package/src/hooks/lib/context.ts +92 -246
  57. package/src/hooks/lib/entities.ts +7 -7
  58. package/src/hooks/lib/frontmatter.ts +4 -4
  59. package/src/hooks/lib/graduation.ts +7 -6
  60. package/src/hooks/lib/inference.ts +6 -2
  61. package/src/hooks/lib/learning-category.ts +1 -1
  62. package/src/hooks/lib/learning-store.ts +6 -1
  63. package/src/hooks/lib/notify.ts +2 -2
  64. package/src/hooks/lib/opinions.ts +3 -3
  65. package/src/hooks/lib/paths.ts +2 -0
  66. package/src/hooks/lib/projects.ts +142 -74
  67. package/src/hooks/lib/readme-sync.ts +1 -1
  68. package/src/hooks/lib/relationship.ts +4 -16
  69. package/src/hooks/lib/retrieval-index.ts +5 -3
  70. package/src/hooks/lib/retrieval.ts +11 -12
  71. package/src/hooks/lib/security.ts +24 -18
  72. package/src/hooks/lib/semi-static.ts +188 -0
  73. package/src/hooks/lib/session-names.ts +1 -1
  74. package/src/hooks/lib/settings.ts +1 -1
  75. package/src/hooks/lib/setup.ts +2 -65
  76. package/src/hooks/lib/signals.ts +2 -2
  77. package/src/hooks/lib/stdin.ts +1 -1
  78. package/src/hooks/lib/stop.ts +16 -6
  79. package/src/hooks/lib/token-usage.ts +1 -2
  80. package/src/hooks/lib/transcript.ts +1 -1
  81. package/src/hooks/lib/wisdom.ts +5 -5
  82. package/src/hooks/lib/work-tracking.ts +8 -14
  83. package/src/targets/claude/uninstall.ts +1 -1
  84. package/src/targets/codex/install.ts +95 -0
  85. package/src/targets/codex/uninstall.ts +70 -0
  86. package/src/targets/copilot/install.ts +39 -8
  87. package/src/targets/copilot/uninstall.ts +58 -17
  88. package/src/targets/cursor/install.ts +8 -0
  89. package/src/targets/cursor/uninstall.ts +18 -1
  90. package/src/targets/lib.ts +166 -14
  91. package/src/targets/opencode/install.ts +29 -1
  92. package/src/targets/opencode/plugin.ts +23 -12
  93. package/src/targets/opencode/uninstall.ts +30 -3
  94. package/src/tools/agent/algorithm-reflect.ts +1 -1
  95. package/src/tools/agent/analyze.ts +18 -18
  96. package/src/tools/agent/handoff-note.ts +116 -0
  97. package/src/tools/agent/project.ts +375 -75
  98. package/src/tools/agent/relationship-note.ts +51 -0
  99. package/src/tools/agent/synthesize.ts +6 -42
  100. package/src/tools/agent/thread.ts +15 -14
  101. package/src/tools/agent/wisdom-frame.ts +9 -3
  102. package/src/tools/import.ts +1 -1
  103. package/src/tools/relationship-reflect.ts +15 -13
  104. package/src/tools/self-model.ts +23 -19
  105. package/src/tools/session-summary.ts +3 -3
  106. package/src/tools/token-cost.ts +15 -16
  107. package/assets/skills/telos/tools/update-projects.ts +0 -106
  108. package/assets/templates/telos/PROJECTS.md +0 -7
@@ -1,78 +1,14 @@
1
1
  /**
2
2
  * Interactive TELOS setup — prompts for personal context during `pal install`.
3
3
  * Skips any step whose TELOS file already has real content.
4
- * Projects use the upsertProject tool directly with a structured add-another loop.
5
4
  */
6
5
 
7
6
  import { writeFileSync } from "node:fs";
8
7
  import { resolve } from "node:path";
9
8
  import * as clack from "@clack/prompts";
10
- import { upsertProject } from "../../assets/skills/telos/tools/update-projects";
11
9
  import { palHome } from "../hooks/lib/paths";
12
10
  import { hasRealContent, SETUP_STEPS, STEP_ORDER } from "../hooks/lib/setup";
13
11
 
14
- function toKebabCase(name: string): string {
15
- return name
16
- .toLowerCase()
17
- .replace(/[^a-z0-9]+/g, "-")
18
- .replace(/^-|-$/g, "");
19
- }
20
-
21
- async function promptProjectsLoop(): Promise<void> {
22
- const addFirst = await clack.confirm({
23
- message: "Do you want to add any projects now?",
24
- initialValue: true,
25
- });
26
- if (clack.isCancel(addFirst) || !addFirst) return;
27
-
28
- let addMore = true;
29
- while (addMore) {
30
- const name = await clack.text({
31
- message: "Project name?",
32
- placeholder: "e.g. PAL, My SaaS, Work Dashboard",
33
- });
34
- if (clack.isCancel(name)) return;
35
-
36
- const status = await clack.select({
37
- message: "Status?",
38
- options: [
39
- { value: "Active", label: "Active" },
40
- { value: "Planning", label: "Planning" },
41
- { value: "Paused", label: "Paused" },
42
- { value: "Complete", label: "Complete" },
43
- ],
44
- });
45
- if (clack.isCancel(status)) return;
46
-
47
- const priority = await clack.select({
48
- message: "Priority?",
49
- options: [
50
- { value: "High", label: "High" },
51
- { value: "Medium", label: "Medium" },
52
- { value: "Low", label: "Low" },
53
- ],
54
- });
55
- if (clack.isCancel(priority)) return;
56
-
57
- const notes = await clack.text({
58
- message: "Notes? (optional — leave blank to skip)",
59
- placeholder: "e.g. Building the v2 API, blocked on design review",
60
- });
61
- if (clack.isCancel(notes)) return;
62
-
63
- const id = toKebabCase(name as string);
64
- const row = `| ${id} | ${name} | ${status} | ${priority} | ${notes || ""} |`;
65
- upsertProject(id, row, `Added ${name} during PAL setup`);
66
- clack.log.success(`Added: ${name}`);
67
-
68
- const again = await clack.confirm({
69
- message: "Add another project?",
70
- initialValue: false,
71
- });
72
- if (clack.isCancel(again) || !again) addMore = false;
73
- }
74
- }
75
-
76
12
  /** Prompt for missing TELOS context. Skips any step whose file already has real content. */
77
13
  export async function promptTelos(): Promise<void> {
78
14
  // Skip interactive prompts in non-TTY environments (tests, CI)
@@ -95,25 +31,21 @@ export async function promptTelos(): Promise<void> {
95
31
  );
96
32
 
97
33
  for (const key of pending) {
98
- if (key === "projects") {
99
- await promptProjectsLoop();
100
- } else {
101
- const step = SETUP_STEPS[key];
102
- const title = key.charAt(0).toUpperCase() + key.slice(1);
103
-
104
- const answer = await clack.text({
105
- message: step.question,
106
- placeholder: step.hint,
107
- });
34
+ const step = SETUP_STEPS[key];
35
+ const title = key.charAt(0).toUpperCase() + key.slice(1);
108
36
 
109
- if (clack.isCancel(answer)) {
110
- clack.cancel("Setup cancelled");
111
- return;
112
- }
37
+ const answer = await clack.text({
38
+ message: step.question,
39
+ placeholder: step.hint,
40
+ });
113
41
 
114
- const filePath = resolve(home, step.file);
115
- writeFileSync(filePath, `# ${title}\n\n${answer}\n`, "utf-8");
42
+ if (clack.isCancel(answer)) {
43
+ clack.cancel("Setup cancelled");
44
+ return;
116
45
  }
46
+
47
+ const filePath = resolve(home, step.file);
48
+ writeFileSync(filePath, `# ${title}\n\n${answer}\n`, "utf-8");
117
49
  }
118
50
 
119
51
  clack.outro("Personal context saved ✓");
@@ -8,8 +8,10 @@
8
8
  * Storage: ~/.pal/memory/state/last-exchange/{session_id}.json (with latest.json fallback).
9
9
  */
10
10
 
11
- import { existsSync, readFileSync, unlinkSync } from "node:fs";
11
+ import { existsSync } from "node:fs";
12
+ import { readFile, unlink } from "node:fs/promises";
12
13
  import { resolve } from "node:path";
14
+ import { isCursor } from "./lib/agent";
13
15
  import { logDebug, logError } from "./lib/log";
14
16
  import { paths } from "./lib/paths";
15
17
  import { readStdinJSON } from "./lib/stdin";
@@ -61,7 +63,7 @@ const main = async () => {
61
63
  process.exit(0);
62
64
  }
63
65
 
64
- const saved = JSON.parse(readFileSync(file, "utf-8")) as SavedExchange;
66
+ const saved = JSON.parse(await readFile(file, "utf-8")) as SavedExchange;
65
67
  const userBudget = Math.floor(MAX_OUTPUT * 0.4);
66
68
  const assistantBudget = MAX_OUTPUT - userBudget - 300; // reserve for framing
67
69
 
@@ -81,7 +83,11 @@ const main = async () => {
81
83
  "</system-reminder>",
82
84
  ].join("\n");
83
85
 
84
- process.stdout.write(out);
86
+ if (isCursor()) {
87
+ process.stdout.write(JSON.stringify({ additional_context: out }));
88
+ } else {
89
+ process.stdout.write(out);
90
+ }
85
91
  logDebug("CompactRecover", `Re-injected ${out.length} chars from ${file}`);
86
92
 
87
93
  // Consume-on-read: drop the session-keyed file after a successful injection so it
@@ -90,7 +96,7 @@ const main = async () => {
90
96
  const sessionFile = sessionId ? resolve(stateDir, `${sessionId}.json`) : null;
91
97
  if (sessionFile && file === sessionFile) {
92
98
  try {
93
- unlinkSync(sessionFile);
99
+ await unlink(sessionFile);
94
100
  } catch (err) {
95
101
  logError("CompactRecover:cleanup", err);
96
102
  }
@@ -102,4 +108,4 @@ const main = async () => {
102
108
  process.exit(0);
103
109
  };
104
110
 
105
- main();
111
+ await main();
@@ -9,10 +9,10 @@
9
9
  * context directly to ~/.copilot/copilot-instructions.md so it is picked up on load.
10
10
  */
11
11
 
12
- import { existsSync, lstatSync, unlinkSync, writeFileSync } from "node:fs";
12
+ import { mkdirSync, writeFileSync } from "node:fs";
13
13
  import { resolve } from "node:path";
14
14
  import { buildClaudeMd, regenerateIfNeeded } from "./lib/claude-md";
15
- import { buildSystemReminder } from "./lib/context";
15
+ import { type AgentTarget, buildSystemReminder } from "./lib/context";
16
16
  import { logDebug, logError } from "./lib/log";
17
17
  import { platform } from "./lib/paths";
18
18
 
@@ -36,25 +36,49 @@ try {
36
36
 
37
37
  // --- Context to stdout (or file for Copilot) ---
38
38
  try {
39
- const reminder = buildSystemReminder();
39
+ // Determine agent target — controls which sections are skipped (loaded natively instead).
40
+ let agent: AgentTarget = "claude";
41
+ if (process.env.PAL_AGENT === "copilot") agent = "copilot";
42
+ else if (process.env.PAL_AGENT === "cursor" || process.env.CURSOR_VERSION)
43
+ agent = "cursor";
44
+ const reminder = buildSystemReminder({ agent });
40
45
  if (!reminder) process.exit(0);
41
46
 
42
47
  if (process.env.PAL_AGENT === "copilot") {
43
- // Copilot: sessionStart output is ignored write merged context to copilot-instructions.md
44
- const instructionsPath = resolve(platform.copilotDir(), "copilot-instructions.md");
48
+ // Copilot: semi-static in ~/.copilot/instructions/pal-*.instructions.md (written at stop).
49
+ // Write AGENTS.md + dynamic context to pal-session.instructions.md on each session start.
50
+ const instructionsDir = resolve(platform.copilotDir(), "instructions");
51
+ mkdirSync(instructionsDir, { recursive: true });
45
52
  const agentsMd = buildClaudeMd();
46
53
  const context = [agentsMd, reminder].filter(Boolean).join("\n\n");
47
- if (existsSync(instructionsPath) && lstatSync(instructionsPath).isSymbolicLink()) {
48
- unlinkSync(instructionsPath);
54
+ if (context) {
55
+ writeFileSync(
56
+ resolve(instructionsDir, "pal-session.instructions.md"),
57
+ `---\napplyTo: "**"\n---\n\n${context}`,
58
+ "utf-8"
59
+ );
49
60
  }
50
- writeFileSync(instructionsPath, context, "utf-8");
51
- logDebug("LoadContext", `Copilot instructions written: ${context.length} chars`);
52
- } else if (process.env.CURSOR_VERSION) {
53
- // Cursor: no native user-level rules — inject AGENTS.md + dynamic context
61
+ logDebug(
62
+ "LoadContext",
63
+ `Copilot session instructions written: ${context.length} chars`
64
+ );
65
+ } else if (process.env.PAL_AGENT === "cursor" || process.env.CURSOR_VERSION) {
66
+ // Cursor: semi-static in ~/.cursor/rules/pal-context.mdc; inject AGENTS.md + dynamic here
54
67
  const agentsMd = buildClaudeMd();
55
68
  const context = [agentsMd, reminder].filter(Boolean).join("\n\n");
56
69
  process.stdout.write(JSON.stringify({ additional_context: context }));
57
70
  logDebug("LoadContext", `Reminder injected: ${reminder.length} chars`);
71
+ } else if (process.env.PAL_AGENT === "codex") {
72
+ // Codex: AGENTS.md already loaded via symlink; inject only dynamic context
73
+ process.stdout.write(
74
+ JSON.stringify({
75
+ hookSpecificOutput: {
76
+ hookEventName: "SessionStart",
77
+ additionalContext: reminder,
78
+ },
79
+ })
80
+ );
81
+ logDebug("LoadContext", `Codex reminder injected: ${reminder.length} chars`);
58
82
  } else {
59
83
  // Claude Code: raw text
60
84
  console.log(reminder);
@@ -11,17 +11,14 @@
11
11
  * is currently a Claude Code event.
12
12
  */
13
13
 
14
- import { writeFileSync } from "node:fs";
14
+ import { existsSync } from "node:fs";
15
+ import { readFile } from "node:fs/promises";
15
16
  import { resolve } from "node:path";
17
+ import { persistLastExchange } from "./handlers/persist-last-exchange";
16
18
  import { logDebug, logError } from "./lib/log";
17
- import { ensureDir, paths } from "./lib/paths";
19
+ import { paths } from "./lib/paths";
18
20
  import { readStdinJSON } from "./lib/stdin";
19
- import {
20
- extractContent,
21
- extractLastAssistant,
22
- extractLastUser,
23
- readTranscriptFile,
24
- } from "./lib/transcript";
21
+ import { readTranscriptFile } from "./lib/transcript";
25
22
 
26
23
  interface PreCompactInput {
27
24
  session_id?: string;
@@ -45,36 +42,31 @@ const main = async () => {
45
42
  logDebug("PreCompactPersist", "Transcript empty or unreadable");
46
43
  process.exit(0);
47
44
  }
45
+ const sessionId = input.session_id ?? "unknown";
46
+ const cwd = input.cwd ?? process.cwd();
48
47
 
49
- const lastUser = extractContent(extractLastUser(messages));
50
- const lastAssistant = extractContent(extractLastAssistant(messages));
51
-
52
- if (!lastUser && !lastAssistant) {
53
- logDebug("PreCompactPersist", "No user/assistant text found in transcript");
54
- process.exit(0);
48
+ // Stop fires after every response and is authoritative. Skip if it already
49
+ // wrote latest.json for this session — PreCompact is a safety net only.
50
+ const latestPath = resolve(paths.state(), "last-exchange", "latest.json");
51
+ if (existsSync(latestPath)) {
52
+ try {
53
+ const latest = JSON.parse(await readFile(latestPath, "utf-8"));
54
+ if (latest?.sessionId === sessionId) {
55
+ logDebug(
56
+ "PreCompactPersist",
57
+ `Stop already persisted session ${sessionId} — skipping`
58
+ );
59
+ process.exit(0);
60
+ }
61
+ } catch {
62
+ /* unreadable — fall through and write */
63
+ }
55
64
  }
56
65
 
57
- const sessionId = input.session_id || "unknown";
58
- const payload = {
59
- sessionId,
60
- timestamp: new Date().toISOString(),
61
- trigger: input.trigger ?? null,
62
- customInstructions: input.custom_instructions || null,
63
- userMessage: lastUser,
64
- assistantMessage: lastAssistant,
65
- };
66
-
67
- const stateDir = ensureDir(resolve(paths.state(), "last-exchange"));
68
- const sessionFile = resolve(stateDir, `${sessionId}.json`);
69
- const latestFile = resolve(stateDir, "latest.json");
70
- const json = `${JSON.stringify(payload, null, 2)}\n`;
71
-
72
- writeFileSync(sessionFile, json, "utf-8");
73
- writeFileSync(latestFile, json, "utf-8");
74
-
66
+ persistLastExchange(messages, sessionId, cwd);
75
67
  logDebug(
76
68
  "PreCompactPersist",
77
- `Saved last exchange (user=${lastUser.length}ch, assistant=${lastAssistant.length}ch) for session ${sessionId}`
69
+ `Persisted exchange before compaction for session ${sessionId}`
78
70
  );
79
71
  } catch (err) {
80
72
  logError("PreCompactPersist", err);
@@ -83,4 +75,4 @@ const main = async () => {
83
75
  process.exit(0);
84
76
  };
85
77
 
86
- main();
78
+ await main();
@@ -6,47 +6,69 @@
6
6
  */
7
7
 
8
8
  import { blockResponse } from "./lib/agent";
9
- import { checkBashCommand, checkFilePath, WARN_COMMANDS } from "./lib/security";
9
+ import { checkBashCommand, checkFilePath } from "./lib/security";
10
10
  import { readStdinJSON } from "./lib/stdin";
11
11
 
12
+ // preToolUse shape (Claude Code + Cursor + Codex)
12
13
  interface ToolUseInput {
13
14
  tool_name: string;
15
+ hook_event_name?: string; // Codex includes this in all hook inputs
14
16
  tool_input: {
15
17
  command?: string;
16
18
  file_path?: string;
17
19
  };
18
20
  }
19
21
 
20
- try {
21
- const input = await readStdinJSON<ToolUseInput>();
22
- if (!input) process.exit(0);
22
+ // beforeShellExecution shape (Cursor only) — flat, no tool_name wrapper
23
+ interface ShellExecInput {
24
+ command: string;
25
+ sandbox?: boolean;
26
+ }
23
27
 
24
- const { tool_name, tool_input } = input;
28
+ type SecurityInput = ToolUseInput | ShellExecInput;
25
29
 
26
- // Normalize tool names: Claude uses "Bash", Cursor uses "Shell"
27
- const isBash = tool_name === "Bash" || tool_name === "Shell";
28
- const isFileWrite = tool_name === "Write" || tool_name === "Edit";
30
+ function isShellExec(input: SecurityInput): input is ShellExecInput {
31
+ return !("tool_name" in input) && "command" in input;
32
+ }
33
+
34
+ try {
35
+ const input = await readStdinJSON<SecurityInput>();
36
+ if (!input) process.exit(0);
29
37
 
30
- // Check shell commands
31
- if (isBash && tool_input.command) {
32
- const reason = checkBashCommand(tool_input.command);
38
+ if (isShellExec(input)) {
39
+ // beforeShellExecution command is always a shell command
40
+ const reason = checkBashCommand(input.command);
33
41
  if (reason) {
34
42
  process.stdout.write(blockResponse(`Blocked: ${reason}`));
35
- process.exit(0);
36
43
  }
44
+ process.exit(0);
45
+ }
46
+
47
+ const hookEventName = input.hook_event_name;
48
+
49
+ // preToolUse — Claude: "Bash", Cursor: "Shell", Codex: "shell"
50
+ const isBash =
51
+ input.tool_name === "Bash" ||
52
+ input.tool_name === "Shell" ||
53
+ input.tool_name === "shell";
54
+ const isFileWrite =
55
+ input.tool_name === "Write" ||
56
+ input.tool_name === "Edit" ||
57
+ input.tool_name === "write_file" ||
58
+ input.tool_name === "apply_patch";
37
59
 
38
- for (const pattern of WARN_COMMANDS) {
39
- if (pattern.test(tool_input.command)) {
40
- break;
41
- }
60
+ if (isBash && input.tool_input.command) {
61
+ const reason = checkBashCommand(input.tool_input.command);
62
+ if (reason) {
63
+ process.stdout.write(blockResponse(`Blocked: ${reason}`, hookEventName));
64
+ process.exit(0);
42
65
  }
43
66
  }
44
67
 
45
- // Check file path operations
46
- if (isFileWrite && tool_input.file_path) {
47
- const fileReason = checkFilePath(tool_input.file_path);
48
- if (fileReason) {
49
- process.stdout.write(blockResponse(fileReason));
68
+ if (isFileWrite && input.tool_input.file_path) {
69
+ const reason = checkFilePath(input.tool_input.file_path);
70
+ if (reason) {
71
+ process.stdout.write(blockResponse(reason, hookEventName));
50
72
  process.exit(0);
51
73
  }
52
74
  }
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import { checkReadmeSync } from "./handlers/readme-sync";
10
- import { isCursor } from "./lib/agent";
10
+ import { isCodex, isCursor } from "./lib/agent";
11
11
  import { logError } from "./lib/log";
12
12
  import { readStdinJSON } from "./lib/stdin";
13
13
  import { runStopHandlers } from "./lib/stop";
@@ -26,6 +26,9 @@ try {
26
26
  if (isCursor()) {
27
27
  // Cursor stop hook: followup_message auto-sends to the agent
28
28
  process.stdout.write(JSON.stringify({ followup_message: decision.reason }));
29
+ } else if (isCodex()) {
30
+ // Codex stop hook: additionalContext re-queues as next prompt
31
+ process.stdout.write(JSON.stringify({ additionalContext: decision.reason }));
29
32
  } else {
30
33
  // Claude Code: block decision
31
34
  process.stdout.write(JSON.stringify(decision));
@@ -16,15 +16,17 @@ import { readStdinJSON } from "./lib/stdin";
16
16
  interface PromptSubmitInput {
17
17
  prompt: string;
18
18
  session_id?: string;
19
+ conversation_id?: string; // Cursor sends this instead of session_id
19
20
  }
20
21
 
21
22
  const input = await readStdinJSON<PromptSubmitInput>();
22
23
  logDebug("UserPromptOrchestrator", `Input: ${JSON.stringify(input).slice(0, 200)}`);
23
24
  if (!input?.prompt) process.exit(0);
24
25
 
26
+ const sessionId = input.session_id ?? input.conversation_id;
25
27
  const results = await Promise.allSettled([
26
- captureRating(input.prompt, input.session_id),
27
- captureSessionName(input.prompt, input.session_id ?? ""),
28
+ captureRating(input.prompt, sessionId),
29
+ captureSessionName(input.prompt, sessionId ?? ""),
28
30
  injectRetrieval(input.prompt),
29
31
  ]);
30
32
 
@@ -77,12 +77,12 @@ function alreadyPromoted(state: GraduationState, pattern: string): boolean {
77
77
  return state.graduated.some((g) => g.pattern === pattern);
78
78
  }
79
79
 
80
- export interface AutoGraduateOptions {
80
+ interface AutoGraduateOptions {
81
81
  /** Bypass the 24h TTL guard. State + content dedup still apply. */
82
82
  force?: boolean;
83
83
  }
84
84
 
85
- export interface AutoGraduateResult {
85
+ interface AutoGraduateResult {
86
86
  ranAnalysis: boolean;
87
87
  candidatesAtFloor: number;
88
88
  promoted: number;
@@ -4,7 +4,7 @@
4
4
  * is older than 7 days, or if no backup exists yet.
5
5
  */
6
6
 
7
- import { readdirSync, statSync } from "node:fs";
7
+ import { readdir, stat } from "node:fs/promises";
8
8
  import { resolve } from "node:path";
9
9
  import { exportZip, timestamp } from "../lib/export";
10
10
  import { logDebug } from "../lib/log";
@@ -16,14 +16,14 @@ export async function autoBackup(): Promise<void> {
16
16
  const backupDir = paths.backups();
17
17
 
18
18
  // Check most recent backup
19
- const existing = readdirSync(backupDir)
19
+ const existing = (await readdir(backupDir))
20
20
  .filter((f) => f.startsWith("pal-backup-") && f.endsWith(".zip"))
21
21
  .sort()
22
22
  .reverse();
23
23
 
24
24
  if (existing.length > 0) {
25
25
  const latestPath = resolve(backupDir, existing[0]);
26
- const latestMtime = statSync(latestPath).mtimeMs;
26
+ const latestMtime = (await stat(latestPath)).mtimeMs;
27
27
  if (Date.now() - latestMtime < BACKUP_INTERVAL_MS) {
28
28
  logDebug("backup", "Skipping — last backup is less than 7 days old");
29
29
  return;
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Handler: write pre-compiled context digest files for @import / instructions[].
3
+ *
4
+ * Runs at session stop so that CLAUDE.md can @import these files natively
5
+ * at the next session start, keeping hook stdout small.
6
+ *
7
+ * Sources are defined in src/hooks/lib/semi-static.ts — add one entry there
8
+ * to extend coverage to all consumers (CLAUDE.md, opencode, Cursor, Copilot).
9
+ */
10
+
11
+ import { existsSync, writeFileSync } from "node:fs";
12
+ import { dirname, resolve } from "node:path";
13
+ import { ensureDir, platform } from "../lib/paths";
14
+ import {
15
+ copilotFilename,
16
+ cursorFilename,
17
+ getSemiStaticSources,
18
+ } from "../lib/semi-static";
19
+
20
+ export function writeContextDigests(): void {
21
+ const sources = getSemiStaticSources();
22
+
23
+ // Resolve Cursor/Copilot destination dirs once (null if agent not installed)
24
+ let rulesDir: string | null = null;
25
+ let instructionsDir: string | null = null;
26
+
27
+ try {
28
+ const cursorDir = platform.cursorDir();
29
+ if (existsSync(cursorDir)) {
30
+ rulesDir = ensureDir(resolve(cursorDir, "rules"));
31
+ }
32
+ } catch {
33
+ /* non-fatal */
34
+ }
35
+
36
+ try {
37
+ const copilotDir = platform.copilotDir();
38
+ if (existsSync(copilotDir)) {
39
+ instructionsDir = ensureDir(resolve(copilotDir, "instructions"));
40
+ }
41
+ } catch {
42
+ /* non-fatal */
43
+ }
44
+
45
+ for (const src of sources) {
46
+ try {
47
+ const content = src.load();
48
+ if (!content) continue;
49
+
50
+ if (src.writesDigest) {
51
+ ensureDir(dirname(src.path));
52
+ writeFileSync(src.path, content, "utf-8");
53
+ }
54
+
55
+ if (rulesDir) {
56
+ writeFileSync(
57
+ resolve(rulesDir, cursorFilename(src)),
58
+ `---\ndescription: ${src.description}\nalwaysApply: true\n---\n\n${content}`,
59
+ "utf-8"
60
+ );
61
+ }
62
+
63
+ if (instructionsDir) {
64
+ writeFileSync(
65
+ resolve(instructionsDir, copilotFilename(src)),
66
+ `---\napplyTo: "**"\n---\n\n${content}`,
67
+ "utf-8"
68
+ );
69
+ }
70
+ } catch {
71
+ /* non-fatal */
72
+ }
73
+ }
74
+ }
@@ -7,7 +7,7 @@
7
7
  * Analysis is left to the human or the graduation pipeline, not auto-generated.
8
8
  */
9
9
 
10
- import { writeFileSync } from "node:fs";
10
+ import { writeFile } from "node:fs/promises";
11
11
  import { resolve } from "node:path";
12
12
  import { stringify } from "../lib/frontmatter";
13
13
  import { ensureDir, paths } from "../lib/paths";
@@ -31,7 +31,8 @@ export async function captureFailure(
31
31
  context: string,
32
32
  transcript: string,
33
33
  detailedContext?: string,
34
- principle?: string
34
+ principle?: string,
35
+ cwd?: string
35
36
  ): Promise<void> {
36
37
  const messages = parseMessages(transcript);
37
38
 
@@ -57,6 +58,7 @@ export async function captureFailure(
57
58
  slug,
58
59
  };
59
60
  if (principle) meta.principle = principle;
61
+ if (cwd) meta.cwd = cwd;
60
62
 
61
63
  const body = [
62
64
  "## What Happened",
@@ -69,5 +71,5 @@ export async function captureFailure(
69
71
  conversationSummary || "*(unavailable)*",
70
72
  ].join("\n");
71
73
 
72
- writeFileSync(resolve(dir, "capture.md"), stringify(meta, body), "utf-8");
74
+ await writeFile(resolve(dir, "capture.md"), stringify(meta, body), "utf-8");
73
75
  }
@@ -7,6 +7,7 @@
7
7
  * timeout produces empty output, never blocks the prompt.
8
8
  */
9
9
 
10
+ import { isCodex, isCursor } from "../lib/agent";
10
11
  import { logDebug, logError } from "../lib/log";
11
12
  import { runRetrieval } from "../lib/retrieval";
12
13
  import { ensureIndex } from "../lib/retrieval-index";
@@ -29,9 +30,10 @@ function withTimeout<T>(work: () => T, ms: number): Promise<T | null> {
29
30
  });
30
31
  }
31
32
 
32
- export async function injectRetrieval(prompt: string): Promise<void> {
33
- if (!prompt?.trim()) return;
34
- if (!isEnabled("learningInjection")) return;
33
+ /** Returns the retrieval reminder string, or null if nothing to inject. @lintignore dynamically imported by opencode plugin */
34
+ export async function getRetrievalReminder(prompt: string): Promise<string | null> {
35
+ if (!prompt?.trim()) return null;
36
+ if (!isEnabled("learningInjection")) return null;
35
37
 
36
38
  const result = await withTimeout(() => {
37
39
  const index = ensureIndex();
@@ -39,12 +41,33 @@ export async function injectRetrieval(prompt: string): Promise<void> {
39
41
  return runRetrieval(prompt, index, process.cwd());
40
42
  }, TIMEOUT_MS);
41
43
 
42
- if (!result?.reminder) return;
44
+ if (!result?.reminder) return null;
43
45
 
44
46
  logDebug(
45
47
  "inject-retrieval",
46
- `injected ${result.matches.length} matches; top score=${result.matches[0]?.confidence.toFixed(3)}`
48
+ `${result.matches.length} matches; top score=${result.matches[0]?.confidence.toFixed(3)}`
47
49
  );
48
50
 
49
- process.stdout.write(`${result.reminder}\n`);
51
+ return result.reminder;
52
+ }
53
+
54
+ /** Write retrieval reminder to stdout in the correct format for the current agent.
55
+ * Claude Code: plain text. Cursor: { additional_context }. Codex: hookSpecificOutput JSON. */
56
+ export async function injectRetrieval(prompt: string): Promise<void> {
57
+ const reminder = await getRetrievalReminder(prompt);
58
+ if (!reminder) return;
59
+ if (isCursor()) {
60
+ process.stdout.write(JSON.stringify({ additional_context: reminder }));
61
+ } else if (isCodex()) {
62
+ process.stdout.write(
63
+ JSON.stringify({
64
+ hookSpecificOutput: {
65
+ hookEventName: "UserPromptSubmit",
66
+ additionalContext: reminder,
67
+ },
68
+ })
69
+ );
70
+ } else {
71
+ process.stdout.write(`${reminder}\n`);
72
+ }
50
73
  }