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
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Shared handler: persist the last user/assistant exchange on every Stop and PreCompact.
3
+ *
4
+ * Writes two outputs:
5
+ * 1. last-exchange/{sessionId}.json + last-exchange/latest.json
6
+ * → read by CompactRecover to re-inject after compaction
7
+ * 2. last-handoff.json keyed by cwd
8
+ * → read by loadHandoff() to surface "Pick Up Where You Left Off"
9
+ *
10
+ * Always overwrites — Stop is the source of truth for both. The LEARN-phase
11
+ * handoff-note.ts tool may also write to last-handoff.json; whichever runs last wins,
12
+ * but raw exchange is sufficient for continuity and costs nothing.
13
+ */
14
+
15
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
16
+ import { resolve } from "node:path";
17
+ import { logDebug, logError } from "../lib/log";
18
+ import { ensureDir, paths } from "../lib/paths";
19
+ import { extractContent, extractLastAssistant, extractLastUser } from "../lib/transcript";
20
+ import { detectStatus } from "../lib/work-tracking";
21
+
22
+ type ParsedMessage = { role: string; content: unknown };
23
+
24
+ export function persistLastExchange(
25
+ messages: ParsedMessage[],
26
+ sessionId: string,
27
+ cwd: string = process.cwd()
28
+ ): void {
29
+ try {
30
+ const lastUser = extractContent(extractLastUser(messages));
31
+ const lastAssistant = extractContent(extractLastAssistant(messages));
32
+ if (!lastUser && !lastAssistant) return;
33
+
34
+ // 1. Write last-exchange files for CompactRecover
35
+ const stateDir = ensureDir(resolve(paths.state(), "last-exchange"));
36
+ const payload = {
37
+ sessionId,
38
+ timestamp: new Date().toISOString(),
39
+ trigger: null,
40
+ customInstructions: null,
41
+ userMessage: lastUser,
42
+ assistantMessage: lastAssistant,
43
+ };
44
+ const json = `${JSON.stringify(payload, null, 2)}\n`;
45
+ writeFileSync(resolve(stateDir, `${sessionId}.json`), json, "utf-8");
46
+ writeFileSync(resolve(stateDir, "latest.json"), json, "utf-8");
47
+
48
+ // 2. Write last-handoff.json for "Pick Up Where You Left Off"
49
+ const handoffPath = resolve(paths.state(), "last-handoff.json");
50
+ const existing: Record<string, unknown> = existsSync(handoffPath)
51
+ ? JSON.parse(readFileSync(handoffPath, "utf-8"))
52
+ : {};
53
+ const title = (lastUser.slice(0, 80).replace(/\n/g, " ") || "Session").trim();
54
+ const handoff = [
55
+ lastUser ? `Last user message:\n${lastUser.slice(0, 500)}` : "",
56
+ lastAssistant ? `\nLast assistant response:\n${lastAssistant.slice(0, 500)}` : "",
57
+ ]
58
+ .filter(Boolean)
59
+ .join("");
60
+ existing[cwd] = {
61
+ timestamp: new Date().toISOString(),
62
+ title,
63
+ status: detectStatus(lastAssistant),
64
+ handoff,
65
+ artifacts: [],
66
+ };
67
+ writeFileSync(handoffPath, JSON.stringify(existing, null, 2), "utf-8");
68
+
69
+ logDebug(
70
+ "persist-last-exchange",
71
+ `Persisted exchange for session ${sessionId} (user=${lastUser.length}ch, assistant=${lastAssistant.length}ch)`
72
+ );
73
+ } catch (err) {
74
+ logError("persist-last-exchange", err);
75
+ }
76
+ }
@@ -48,7 +48,7 @@ export function parseExplicitRating(
48
48
  prompt: string
49
49
  ): { rating: number; comment?: string } | null {
50
50
  const trimmed = prompt.trim();
51
- const match = trimmed.match(/^(10|[1-9])(?:\s*[-:,]\s*|\s+)?(.*)$/);
51
+ const match = new RegExp(/^(10|[1-9])(?:\s*[-:,]\s*|\s+)?(.*)$/).exec(trimmed);
52
52
  if (!match) return null;
53
53
 
54
54
  const rating = parseInt(match[1], 10);
@@ -271,6 +271,7 @@ function handleRating(
271
271
  principle,
272
272
  responsePreview,
273
273
  userPreview,
274
+ cwd: process.cwd(),
274
275
  ts: now(),
275
276
  },
276
277
  null,
@@ -33,7 +33,7 @@ function hasDocumentableChanges(): boolean {
33
33
  }
34
34
  }
35
35
 
36
- export interface ReadmeSyncDecision {
36
+ interface ReadmeSyncDecision {
37
37
  decision?: "block";
38
38
  reason?: string;
39
39
  }
@@ -50,9 +50,10 @@ export function checkReadmeSync(): ReadmeSyncDecision {
50
50
 
51
51
  if (!result.ok) {
52
52
  logDebug("readme-sync", `README out of sync: ${result.issues.join("; ")}`);
53
+ const issueList = result.issues.map((i) => `- ${i}`).join("\n");
53
54
  return {
54
55
  decision: "block",
55
- reason: `README.md is out of date. Please update it before finishing:\n${result.issues.map((i) => `- ${i}`).join("\n")}`,
56
+ reason: `README.md is out of date. Please update it before finishing:\n${issueList}`,
56
57
  };
57
58
  }
58
59
 
@@ -1,21 +1,22 @@
1
1
  /**
2
2
  * Stop handler: unified session intelligence capture.
3
3
  *
4
- * Merges work-learning + relationship + handoff into a single Haiku call.
5
- * Produces: title, summary, insights, handoff, relationship observations.
6
- * Writes: session learning file, project history, relationship notes, last-handoff.
4
+ * Produces: title, summary, insights via Haiku.
5
+ * Writes: session learning file, project history.
6
+ *
7
+ * Relationship notes → written in ALGORITHM LEARN phase via relationship-note.ts
8
+ * Handoff notes → written in ALGORITHM LEARN phase via handoff-note.ts
7
9
  *
8
- * Replaces: work-learning.ts + relationship.ts (both still exist but are bypassed).
9
10
  */
10
11
 
11
- import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
12
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
13
+ import { unlink, writeFile } from "node:fs/promises";
12
14
  import { resolve } from "node:path";
13
15
  import { stringify } from "../lib/frontmatter";
14
- import { inference } from "../lib/inference";
16
+ import { hasApiKey, inference } from "../lib/inference";
15
17
  import { categorizeLearning } from "../lib/learning-category";
16
18
  import { logDebug, logError } from "../lib/log";
17
19
  import { ensureDir, paths } from "../lib/paths";
18
- import { appendNotes, hasSessionNotes, type RelationshipNote } from "../lib/relationship";
19
20
  import { fileTimestamp, monthPath } from "../lib/time";
20
21
  import { logTokenUsage } from "../lib/token-usage";
21
22
  import {
@@ -26,7 +27,7 @@ import {
26
27
  } from "../lib/transcript";
27
28
  import { appendProjectHistory, detectStatus } from "../lib/work-tracking";
28
29
 
29
- // ── Dedup tracking (same as work-learning) ──
30
+ // ── Dedup tracking ──
30
31
 
31
32
  interface CaptureEntry {
32
33
  filepath: string;
@@ -110,25 +111,8 @@ const INTELLIGENCE_SCHEMA = {
110
111
  description:
111
112
  "If status is in-progress: what remains to be done, key decisions made, blockers. If completed: empty string.",
112
113
  },
113
- observations: {
114
- type: "array" as const,
115
- items: {
116
- type: "object" as const,
117
- additionalProperties: false,
118
- properties: {
119
- type: {
120
- type: "string" as const,
121
- enum: ["O", "W", "B"],
122
- description: "O=preference, W=world fact, B=what AI did",
123
- },
124
- text: { type: "string" as const },
125
- confidence: { type: "number" as const },
126
- },
127
- required: ["type", "text", "confidence"] as const,
128
- },
129
- },
130
114
  },
131
- required: ["title", "summary", "insights", "handoff", "observations"] as const,
115
+ required: ["title", "summary", "insights", "handoff"] as const,
132
116
  };
133
117
 
134
118
  interface IntelligenceOutput {
@@ -136,7 +120,6 @@ interface IntelligenceOutput {
136
120
  summary: string;
137
121
  insights: string;
138
122
  handoff: string;
139
- observations: Array<{ type: "O" | "W" | "B"; text: string; confidence: number }>;
140
123
  }
141
124
 
142
125
  // ── Main handler ──
@@ -155,14 +138,11 @@ export async function captureSessionIntelligence(
155
138
  }
156
139
 
157
140
  // Skip if no API key
158
- if (!process.env.PAL_ANTHROPIC_API_KEY) {
141
+ if (!hasApiKey()) {
159
142
  logDebug("session-intelligence", "Skipped: no PAL_ANTHROPIC_API_KEY");
160
143
  return;
161
144
  }
162
145
 
163
- // Relationship dedup — skip relationship capture if already done for this session
164
- const skipRelationship = sessionId ? hasSessionNotes(sessionId) : false;
165
-
166
146
  // Extract transcript windows
167
147
  const userMessages = messages
168
148
  .filter((m) => m.role === "user")
@@ -174,14 +154,14 @@ export async function captureSessionIntelligence(
174
154
  const lastUser = extractLastUser(messages);
175
155
  const status = detectStatus(lastAssistantText);
176
156
 
177
- // Wider window: 15 user msgs at 200 chars (relationship needs more context)
178
- const userWindow = userMessages.slice(-15).map((t) => t.slice(0, 200));
157
+ const userWindow = userMessages.slice(-10).map((t) => t.slice(0, 200));
179
158
  const assistantWindow = lastAssistantText.slice(0, 600);
180
159
 
181
160
  if (userWindow.length < 3) return;
182
161
 
183
162
  // Single Haiku call
184
163
  logDebug("session-intelligence", "Calling inference...");
164
+ const numberedMessages = userWindow.map((m, i) => `${i + 1}. ${m}`).join("\n");
185
165
  let output: IntelligenceOutput | null = null;
186
166
  try {
187
167
  const result = await inference({
@@ -195,12 +175,9 @@ export async function captureSessionIntelligence(
195
175
  status === "in-progress"
196
176
  ? "4. handoff: what remains unfinished — decisions made so far, next steps, blockers (2-4 sentences)"
197
177
  : "4. handoff: empty string (session completed)",
198
- skipRelationship
199
- ? "5. observations: empty array (already captured)"
200
- : "5. observations: 0-3 relationship observations. O=preference/opinion, W=world fact, B=what AI did this session (first-person). Be concise.",
201
178
  ].join("\n"),
202
- user: `User messages:\n${userWindow.map((m, i) => `${i + 1}. ${m}`).join("\n")}\n\nLast AI response:\n${assistantWindow}`,
203
- maxTokens: 500,
179
+ user: `User messages:\n${numberedMessages}\n\nLast AI response:\n${assistantWindow}`,
180
+ maxTokens: 350,
204
181
  timeout: 15000,
205
182
  jsonSchema: INTELLIGENCE_SCHEMA,
206
183
  });
@@ -218,8 +195,6 @@ export async function captureSessionIntelligence(
218
195
  const title = output?.title || extractContent(lastUser).slice(0, 80) || "session";
219
196
  const summary = output?.summary || lastAssistantText.slice(0, 600);
220
197
  const insights = output?.insights || "";
221
- const handoff = output?.handoff || "";
222
-
223
198
  // ── Write session learning file ──
224
199
 
225
200
  const category = categorizeLearning(title, summary);
@@ -241,7 +216,6 @@ export async function captureSessionIntelligence(
241
216
  "",
242
217
  "## Insights",
243
218
  insights || "*No insights captured.*",
244
- ...(handoff ? ["", "## Handoff", handoff] : []),
245
219
  ].join("\n");
246
220
 
247
221
  const content = stringify(meta, body);
@@ -251,7 +225,7 @@ export async function captureSessionIntelligence(
251
225
  const prev = getPreviousCapture(sessionId);
252
226
  if (prev?.filepath && existsSync(prev.filepath)) {
253
227
  try {
254
- unlinkSync(prev.filepath);
228
+ await unlink(prev.filepath);
255
229
  } catch {
256
230
  /* ignore */
257
231
  }
@@ -259,7 +233,7 @@ export async function captureSessionIntelligence(
259
233
  }
260
234
 
261
235
  const filepath = resolve(dir, filename);
262
- writeFileSync(filepath, content, "utf-8");
236
+ await writeFile(filepath, content, "utf-8");
263
237
 
264
238
  // Append to per-project history
265
239
  appendProjectHistory(process.cwd(), {
@@ -271,54 +245,4 @@ export async function captureSessionIntelligence(
271
245
 
272
246
  if (sessionId) markCaptured(sessionId, filepath, messages.length);
273
247
  logDebug("session-intelligence", `Learning captured: ${title}`);
274
-
275
- // ── Write relationship notes ──
276
-
277
- if (!skipRelationship && output?.observations && output.observations.length > 0) {
278
- try {
279
- const notes: RelationshipNote[] = output.observations.map((o) => ({
280
- type: o.type,
281
- text: o.text,
282
- confidence: o.confidence,
283
- }));
284
- appendNotes(notes, sessionId);
285
- logDebug(
286
- "session-intelligence",
287
- `${notes.length} relationship observations captured`
288
- );
289
- } catch (err) {
290
- logError("session-intelligence:relationship", err);
291
- }
292
- }
293
-
294
- // ── Write handoff state ──
295
-
296
- if (handoff && status === "in-progress") {
297
- try {
298
- const handoffPath = resolve(ensureDir(paths.state()), "last-handoff.json");
299
- let handoffs: Record<string, unknown> = {};
300
- if (existsSync(handoffPath)) {
301
- try {
302
- handoffs = JSON.parse(readFileSync(handoffPath, "utf-8"));
303
- } catch {
304
- /* fresh */
305
- }
306
- }
307
- handoffs[process.cwd()] = {
308
- timestamp: new Date().toISOString(),
309
- sessionId,
310
- title,
311
- status,
312
- handoff,
313
- artifacts: [],
314
- };
315
- // Keep last 20 projects
316
- const entries = Object.entries(handoffs);
317
- if (entries.length > 20) handoffs = Object.fromEntries(entries.slice(-20));
318
- writeFileSync(handoffPath, JSON.stringify(handoffs, null, 2), "utf-8");
319
- logDebug("session-intelligence", "Handoff state written");
320
- } catch (err) {
321
- logError("session-intelligence:handoff", err);
322
- }
323
- }
324
248
  }
@@ -10,7 +10,7 @@
10
10
  */
11
11
 
12
12
  import { spawn } from "node:child_process";
13
- import { inference } from "../lib/inference";
13
+ import { hasApiKey, inference } from "../lib/inference";
14
14
  import { logDebug, logError } from "../lib/log";
15
15
  import {
16
16
  extractFallbackName,
@@ -42,7 +42,7 @@ export async function captureSessionName(
42
42
  logDebug("session-name", `Named from prompt: "${name}"`);
43
43
 
44
44
  // Spawn detached background process to upgrade with Haiku inference
45
- if (!process.env.PAL_ANTHROPIC_API_KEY) return;
45
+ if (!hasApiKey()) return;
46
46
  try {
47
47
  const promptB64 = Buffer.from(message.slice(0, 800)).toString("base64");
48
48
  const child = spawn(
@@ -3,7 +3,8 @@
3
3
  * Imports synthesize logic directly — no subprocess needed.
4
4
  */
5
5
 
6
- import { existsSync, readFileSync } from "node:fs";
6
+ import { existsSync } from "node:fs";
7
+ import { readFile } from "node:fs/promises";
7
8
  import { resolve } from "node:path";
8
9
  import { logDebug } from "../lib/log";
9
10
  import { paths } from "../lib/paths";
@@ -16,7 +17,9 @@ export async function runSynthesis(): Promise<void> {
16
17
  // Check 24h guard
17
18
  if (existsSync(statePath)) {
18
19
  try {
19
- const data = JSON.parse(readFileSync(statePath, "utf-8")) as { timestamp: string };
20
+ const data = JSON.parse(await readFile(statePath, "utf-8")) as {
21
+ timestamp: string;
22
+ };
20
23
  if (Date.now() - new Date(data.timestamp).getTime() < SYNTHESIS_TTL_MS) {
21
24
  logDebug("synthesis", "Skipped — last synthesis < 24h ago");
22
25
  return;
@@ -4,7 +4,8 @@
4
4
  * scanning directories and JSONL files.
5
5
  */
6
6
 
7
- import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
7
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
8
+ import { writeFile } from "node:fs/promises";
8
9
  import { resolve } from "node:path";
9
10
  import { assets, ensureDir, paths } from "../lib/paths";
10
11
 
@@ -147,5 +148,5 @@ function getCounts(): Counts {
147
148
  export async function updateCounts(): Promise<void> {
148
149
  const counts = getCounts();
149
150
  const countsPath = resolve(ensureDir(paths.state()), "counts.json");
150
- writeFileSync(countsPath, JSON.stringify(counts, null, 2), "utf-8");
151
+ await writeFile(countsPath, JSON.stringify(counts, null, 2), "utf-8");
151
152
  }
@@ -1,40 +1,42 @@
1
1
  /**
2
2
  * Agent detection and output format adapters.
3
3
  *
4
- * Cursor and Claude Code use different JSON contracts for hook I/O.
4
+ * Cursor, Codex, and Claude Code use different JSON contracts for hook I/O.
5
5
  * These helpers normalize the differences so hook handlers stay clean.
6
6
  */
7
7
 
8
- export type AgentType = "claude" | "cursor";
8
+ type AgentType = "claude" | "cursor" | "codex";
9
9
 
10
10
  /** Detect which agent is running via environment variables */
11
- export function detectAgent(): AgentType {
11
+ function detectAgent(): AgentType {
12
+ // PAL_AGENT is set explicitly in hook command prefixes — most reliable signal.
13
+ // IDE env vars (CURSOR_VERSION, CODEX_CLI_VERSION) are NOT reliably forwarded to
14
+ // hook subprocesses, so PAL_AGENT is the primary detection mechanism.
15
+ if (process.env.PAL_AGENT === "cursor") return "cursor";
16
+ if (process.env.PAL_AGENT === "codex") return "codex";
17
+ // Fallbacks for environments that do forward IDE env vars
12
18
  if (process.env.CURSOR_VERSION) return "cursor";
19
+ if (process.env.CODEX_CLI_VERSION ?? process.env.OPENAI_CODEX) return "codex";
13
20
  return "claude";
14
21
  }
15
22
 
16
23
  export const isCursor = () => detectAgent() === "cursor";
24
+ export const isCodex = () => detectAgent() === "codex";
17
25
 
18
26
  /**
19
27
  * Format a "block this action" response for the current agent.
20
- * Claude Code: { decision: "block", reason }
21
- * Cursor: { permission: "deny", user_message }
28
+ * Claude Code: { decision: "block", reason }
29
+ * Cursor preToolUse: { permission: "deny", user_message }
30
+ * Codex PreToolUse: { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny" } }
22
31
  */
23
- export function blockResponse(reason: string): string {
32
+ export function blockResponse(reason: string, hookEventName?: string): string {
24
33
  if (isCursor()) {
25
34
  return JSON.stringify({ permission: "deny", user_message: reason });
26
35
  }
27
- return JSON.stringify({ decision: "block", reason });
28
- }
29
-
30
- /**
31
- * Format sessionStart context injection for the current agent.
32
- * Claude Code: raw text to stdout
33
- * Cursor: { additional_context: "..." }
34
- */
35
- export function sessionStartOutput(context: string): string {
36
- if (isCursor()) {
37
- return JSON.stringify({ additional_context: context });
36
+ if (isCodex() && hookEventName === "PreToolUse") {
37
+ return JSON.stringify({
38
+ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny" },
39
+ });
38
40
  }
39
- return context;
41
+ return JSON.stringify({ decision: "block", reason });
40
42
  }
@@ -1,9 +1,10 @@
1
1
  /**
2
- * Dynamic AGENTS.md generation.
2
+ * Dynamic AGENTS.md / CLAUDE.md generation.
3
3
  *
4
- * AGENTS.md is regenerated when setup.json or any telos file is newer than
5
- * the existing AGENTS.md. The template lives at AGENTS.md.template.
6
- * CLAUDE.md is kept as a symlink pointing to AGENTS.md.
4
+ * AGENTS.md (opencode, codex, copilot) is regenerated when setup.json or any
5
+ * telos file is newer. CLAUDE.md (Claude Code) is a real file — not a symlink —
6
+ * and prepends an @import for the self-model so that large static context loads
7
+ * natively rather than through the hook's stdout.
7
8
  */
8
9
 
9
10
  import {
@@ -19,6 +20,7 @@ import {
19
20
  } from "node:fs";
20
21
  import { dirname, relative, resolve } from "node:path";
21
22
  import { assets, ensureDir, paths, platform } from "./paths";
23
+ import { getSemiStaticSources } from "./semi-static";
22
24
 
23
25
  const TEMPLATE_PATH = assets.agentsMdTemplate();
24
26
 
@@ -70,16 +72,12 @@ function ensureOneSymlink(linkPath: string, targetPath: string): void {
70
72
  }
71
73
  }
72
74
 
73
- /** Ensure all agent symlinks point to the canonical AGENTS.md */
75
+ /** Ensure codex symlink points to the canonical AGENTS.md.
76
+ * CLAUDE.md for Claude Code is a real file written by ensureClaudeCodeMd().
77
+ * Copilot uses ~/.copilot/instructions/*.instructions.md — no symlink needed. */
74
78
  function ensureSymlinks(): void {
75
- const { outputPath, symlinkPath } = getOutputPaths();
76
- ensureOneSymlink(symlinkPath, outputPath);
79
+ const { outputPath } = getOutputPaths();
77
80
  ensureOneSymlink(resolve(platform.codexDir(), "AGENTS.md"), outputPath);
78
- // Copilot instructions — only create if ~/.copilot/ already exists (i.e. Copilot is installed)
79
- const copilotDir = platform.copilotDir();
80
- if (existsSync(copilotDir)) {
81
- ensureOneSymlink(resolve(copilotDir, "copilot-instructions.md"), outputPath);
82
- }
83
81
  }
84
82
 
85
83
  /** Returns true if AGENTS.md needs to be regenerated */
@@ -89,11 +87,12 @@ export function needsRebuild(): boolean {
89
87
 
90
88
  const outputMtime = statSync(outputPath).mtimeMs;
91
89
 
92
- // Collect source files: template + setup.json + identity + PAL docs
90
+ // Collect source files: template + setup.json + identity + PAL docs + @import candidates
93
91
  const sources: string[] = [
94
92
  TEMPLATE_PATH,
95
93
  resolve(paths.state(), "setup.json"),
96
94
  resolve(paths.memory(), "pal-settings.json"),
95
+ ...getSemiStaticSources().map((s) => s.path),
97
96
  ];
98
97
 
99
98
  // Track PAL doc sources for rebuild detection
@@ -124,15 +123,71 @@ export function buildClaudeMd(): string {
124
123
  .replaceAll("{{PRINCIPAL_NAME}}", id.principal.name);
125
124
  }
126
125
 
127
- /** Regenerate AGENTS.md if any source file is newer, and ensure CLAUDE.md symlink exists. Returns true if rebuilt. */
126
+ /** Build @import header lines for CLAUDE.md one line per semi-static file that exists. */
127
+ function buildClaudeCodeImports(): string {
128
+ const claudeDir = platform.claudeDir();
129
+
130
+ const lines = getSemiStaticSources()
131
+ .map((s) => s.path)
132
+ .filter((p) => existsSync(p))
133
+ .map((p) => `@${relative(claudeDir, p).replaceAll("\\", "/")}`);
134
+
135
+ return lines.length > 0 ? `${lines.join("\n")}\n\n` : "";
136
+ }
137
+
138
+ /** Build CLAUDE.md content for Claude Code — prepends @import for self-model. */
139
+ export function buildClaudeCodeMd(): string {
140
+ return buildClaudeCodeImports() + buildClaudeMd();
141
+ }
142
+
143
+ /** Write ~/.claude/CLAUDE.md as a real file (upgrading from symlink if needed).
144
+ * Also rewrites if the @import header has changed (new digest files appeared). */
145
+ function ensureClaudeCodeMd(): void {
146
+ const claudeDir = platform.claudeDir();
147
+ if (!claudeDir) return;
148
+ const claudeMdPath = resolve(claudeDir, "CLAUDE.md");
149
+ const expected = buildClaudeCodeMd();
150
+ try {
151
+ if (existsSync(claudeMdPath) && !lstatSync(claudeMdPath).isSymbolicLink()) {
152
+ const current = readFileSync(claudeMdPath, "utf-8");
153
+ if (current === expected) return; // no change needed
154
+ // @imports changed — rewrite
155
+ } else if (existsSync(claudeMdPath)) {
156
+ unlinkSync(claudeMdPath); // remove symlink
157
+ }
158
+ } catch {
159
+ /* fall through */
160
+ }
161
+ try {
162
+ ensureDir(claudeDir);
163
+ writeFileSync(claudeMdPath, expected, "utf-8");
164
+ } catch {
165
+ /* ignore write errors — non-fatal */
166
+ }
167
+ }
168
+
169
+ /** Regenerate AGENTS.md if any source file is newer, write real CLAUDE.md, ensure other symlinks. Returns true if rebuilt. */
128
170
  export function regenerateIfNeeded(): boolean {
129
171
  const { outputPath } = getOutputPaths();
130
172
  if (!needsRebuild()) {
131
173
  ensureSymlinks();
174
+ ensureClaudeCodeMd();
132
175
  return false;
133
176
  }
134
177
  ensureDir(dirname(outputPath));
135
178
  writeFileSync(outputPath, buildClaudeMd(), "utf-8");
179
+ // Write Claude Code's CLAUDE.md as a real file (removing any existing symlink)
180
+ const claudeDir = platform.claudeDir();
181
+ if (claudeDir) {
182
+ const claudeMdPath = resolve(claudeDir, "CLAUDE.md");
183
+ try {
184
+ if (existsSync(claudeMdPath)) unlinkSync(claudeMdPath);
185
+ ensureDir(claudeDir);
186
+ writeFileSync(claudeMdPath, buildClaudeCodeMd(), "utf-8");
187
+ } catch {
188
+ /* ignore — CLAUDE.md write failure is non-fatal */
189
+ }
190
+ }
136
191
  ensureSymlinks();
137
192
  return true;
138
193
  }