portable-agent-layer 0.36.0 → 0.38.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 (89) hide show
  1. package/README.md +1 -0
  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 -20
  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/tools/update-telos.ts +0 -1
  20. package/assets/templates/PAL/ALGORITHM.md +27 -3
  21. package/assets/templates/hooks.codex.json +44 -0
  22. package/assets/templates/hooks.cursor.json +11 -5
  23. package/package.json +5 -2
  24. package/src/cli/index.ts +113 -17
  25. package/src/cli/migrate.ts +299 -0
  26. package/src/cli/setup-identity.ts +3 -3
  27. package/src/cli/setup-telos.ts +0 -1
  28. package/src/hooks/CompactRecover.ts +11 -5
  29. package/src/hooks/LoadContext.ts +14 -2
  30. package/src/hooks/PreCompactPersist.ts +26 -34
  31. package/src/hooks/SecurityValidator.ts +43 -21
  32. package/src/hooks/StopOrchestrator.ts +4 -1
  33. package/src/hooks/UserPromptOrchestrator.ts +4 -2
  34. package/src/hooks/handlers/auto-graduate.ts +2 -2
  35. package/src/hooks/handlers/backup.ts +3 -3
  36. package/src/hooks/handlers/failure.ts +5 -3
  37. package/src/hooks/handlers/inject-retrieval.ts +29 -6
  38. package/src/hooks/handlers/persist-last-exchange.ts +76 -0
  39. package/src/hooks/handlers/rating.ts +2 -1
  40. package/src/hooks/handlers/readme-sync.ts +3 -2
  41. package/src/hooks/handlers/session-intelligence.ts +9 -8
  42. package/src/hooks/handlers/session-name.ts +2 -2
  43. package/src/hooks/handlers/synthesis.ts +5 -2
  44. package/src/hooks/handlers/update-counts.ts +3 -2
  45. package/src/hooks/lib/agent.ts +20 -18
  46. package/src/hooks/lib/context.ts +45 -117
  47. package/src/hooks/lib/entities.ts +7 -7
  48. package/src/hooks/lib/frontmatter.ts +4 -4
  49. package/src/hooks/lib/graduation.ts +8 -7
  50. package/src/hooks/lib/inference.ts +6 -2
  51. package/src/hooks/lib/learning-category.ts +1 -1
  52. package/src/hooks/lib/learning-store.ts +6 -1
  53. package/src/hooks/lib/notify.ts +2 -2
  54. package/src/hooks/lib/opinions.ts +3 -3
  55. package/src/hooks/lib/paths.ts +2 -0
  56. package/src/hooks/lib/projects.ts +142 -74
  57. package/src/hooks/lib/readme-sync.ts +1 -1
  58. package/src/hooks/lib/relationship.ts +3 -15
  59. package/src/hooks/lib/retrieval-index.ts +5 -3
  60. package/src/hooks/lib/retrieval.ts +11 -12
  61. package/src/hooks/lib/security.ts +22 -18
  62. package/src/hooks/lib/semi-static.ts +4 -2
  63. package/src/hooks/lib/session-names.ts +1 -1
  64. package/src/hooks/lib/settings.ts +1 -1
  65. package/src/hooks/lib/setup.ts +2 -60
  66. package/src/hooks/lib/signals.ts +2 -2
  67. package/src/hooks/lib/stdin.ts +1 -1
  68. package/src/hooks/lib/stop.ts +13 -6
  69. package/src/hooks/lib/token-usage.ts +1 -2
  70. package/src/hooks/lib/transcript.ts +1 -1
  71. package/src/hooks/lib/wisdom.ts +5 -5
  72. package/src/hooks/lib/work-tracking.ts +13 -18
  73. package/src/targets/codex/install.ts +95 -0
  74. package/src/targets/codex/uninstall.ts +70 -0
  75. package/src/targets/lib.ts +140 -14
  76. package/src/targets/opencode/plugin.ts +22 -11
  77. package/src/tools/agent/algorithm-reflect.ts +1 -1
  78. package/src/tools/agent/analyze.ts +18 -18
  79. package/src/tools/agent/handoff-note.ts +1 -1
  80. package/src/tools/agent/project.ts +375 -75
  81. package/src/tools/agent/synthesize.ts +6 -42
  82. package/src/tools/agent/thread.ts +15 -14
  83. package/src/tools/agent/wisdom-frame.ts +9 -3
  84. package/src/tools/import.ts +1 -1
  85. package/src/tools/relationship-reflect.ts +13 -11
  86. package/src/tools/self-model.ts +20 -16
  87. package/src/tools/session-summary.ts +3 -3
  88. package/src/tools/token-cost.ts +15 -16
  89. package/assets/skills/telos/tools/update-projects.ts +0 -106
@@ -39,7 +39,8 @@ try {
39
39
  // Determine agent target — controls which sections are skipped (loaded natively instead).
40
40
  let agent: AgentTarget = "claude";
41
41
  if (process.env.PAL_AGENT === "copilot") agent = "copilot";
42
- else if (process.env.CURSOR_VERSION) agent = "cursor";
42
+ else if (process.env.PAL_AGENT === "cursor" || process.env.CURSOR_VERSION)
43
+ agent = "cursor";
43
44
  const reminder = buildSystemReminder({ agent });
44
45
  if (!reminder) process.exit(0);
45
46
 
@@ -61,12 +62,23 @@ try {
61
62
  "LoadContext",
62
63
  `Copilot session instructions written: ${context.length} chars`
63
64
  );
64
- } else if (process.env.CURSOR_VERSION) {
65
+ } else if (process.env.PAL_AGENT === "cursor" || process.env.CURSOR_VERSION) {
65
66
  // Cursor: semi-static in ~/.cursor/rules/pal-context.mdc; inject AGENTS.md + dynamic here
66
67
  const agentsMd = buildClaudeMd();
67
68
  const context = [agentsMd, reminder].filter(Boolean).join("\n\n");
68
69
  process.stdout.write(JSON.stringify({ additional_context: context }));
69
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`);
70
82
  } else {
71
83
  // Claude Code: raw text
72
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;
@@ -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
  }
@@ -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
 
@@ -7,13 +7,13 @@
7
7
  * Relationship notes → written in ALGORITHM LEARN phase via relationship-note.ts
8
8
  * Handoff notes → written in ALGORITHM LEARN phase via handoff-note.ts
9
9
  *
10
- * Replaces: work-learning.ts (still exists but is bypassed).
11
10
  */
12
11
 
13
- import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
12
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
13
+ import { unlink, writeFile } from "node:fs/promises";
14
14
  import { resolve } from "node:path";
15
15
  import { stringify } from "../lib/frontmatter";
16
- import { inference } from "../lib/inference";
16
+ import { hasApiKey, inference } from "../lib/inference";
17
17
  import { categorizeLearning } from "../lib/learning-category";
18
18
  import { logDebug, logError } from "../lib/log";
19
19
  import { ensureDir, paths } from "../lib/paths";
@@ -27,7 +27,7 @@ import {
27
27
  } from "../lib/transcript";
28
28
  import { appendProjectHistory, detectStatus } from "../lib/work-tracking";
29
29
 
30
- // ── Dedup tracking (same as work-learning) ──
30
+ // ── Dedup tracking ──
31
31
 
32
32
  interface CaptureEntry {
33
33
  filepath: string;
@@ -138,7 +138,7 @@ export async function captureSessionIntelligence(
138
138
  }
139
139
 
140
140
  // Skip if no API key
141
- if (!process.env.PAL_ANTHROPIC_API_KEY) {
141
+ if (!hasApiKey()) {
142
142
  logDebug("session-intelligence", "Skipped: no PAL_ANTHROPIC_API_KEY");
143
143
  return;
144
144
  }
@@ -161,6 +161,7 @@ export async function captureSessionIntelligence(
161
161
 
162
162
  // Single Haiku call
163
163
  logDebug("session-intelligence", "Calling inference...");
164
+ const numberedMessages = userWindow.map((m, i) => `${i + 1}. ${m}`).join("\n");
164
165
  let output: IntelligenceOutput | null = null;
165
166
  try {
166
167
  const result = await inference({
@@ -175,7 +176,7 @@ export async function captureSessionIntelligence(
175
176
  ? "4. handoff: what remains unfinished — decisions made so far, next steps, blockers (2-4 sentences)"
176
177
  : "4. handoff: empty string (session completed)",
177
178
  ].join("\n"),
178
- user: `User messages:\n${userWindow.map((m, i) => `${i + 1}. ${m}`).join("\n")}\n\nLast AI response:\n${assistantWindow}`,
179
+ user: `User messages:\n${numberedMessages}\n\nLast AI response:\n${assistantWindow}`,
179
180
  maxTokens: 350,
180
181
  timeout: 15000,
181
182
  jsonSchema: INTELLIGENCE_SCHEMA,
@@ -224,7 +225,7 @@ export async function captureSessionIntelligence(
224
225
  const prev = getPreviousCapture(sessionId);
225
226
  if (prev?.filepath && existsSync(prev.filepath)) {
226
227
  try {
227
- unlinkSync(prev.filepath);
228
+ await unlink(prev.filepath);
228
229
  } catch {
229
230
  /* ignore */
230
231
  }
@@ -232,7 +233,7 @@ export async function captureSessionIntelligence(
232
233
  }
233
234
 
234
235
  const filepath = resolve(dir, filename);
235
- writeFileSync(filepath, content, "utf-8");
236
+ await writeFile(filepath, content, "utf-8");
236
237
 
237
238
  // Append to per-project history
238
239
  appendProjectHistory(process.cwd(), {
@@ -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
  }