portable-agent-layer 0.94.0 → 0.95.1

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 (50) hide show
  1. package/README.md +2 -0
  2. package/assets/templates/hooks.antigravity.json +1 -1
  3. package/assets/templates/hooks.codex.json +11 -1
  4. package/assets/templates/hooks.copilot.json +11 -4
  5. package/assets/templates/hooks.cursor.json +8 -2
  6. package/assets/templates/settings.claude.json +55 -3
  7. package/package.json +1 -1
  8. package/src/cli/content.ts +23 -0
  9. package/src/cli/events.ts +47 -0
  10. package/src/cli/tree.ts +4 -0
  11. package/src/hooks/AttentionEvent.ts +22 -0
  12. package/src/hooks/LoadContext.ts +2 -2
  13. package/src/hooks/PostToolOrchestrator.ts +20 -0
  14. package/src/hooks/PreCompactPersist.ts +4 -2
  15. package/src/hooks/PreserveTranscripts.ts +13 -0
  16. package/src/hooks/SessionClose.ts +5 -0
  17. package/src/hooks/UserPromptOrchestrator.ts +9 -2
  18. package/src/hooks/lib/attention-events.ts +43 -0
  19. package/src/hooks/lib/auto-update.ts +24 -1
  20. package/src/hooks/lib/commitment-events.ts +20 -0
  21. package/src/hooks/lib/discord-events.ts +25 -0
  22. package/src/hooks/lib/entity-events.ts +16 -0
  23. package/src/hooks/lib/events.ts +252 -0
  24. package/src/hooks/lib/export.ts +1 -1
  25. package/src/hooks/lib/hook-timing.ts +33 -0
  26. package/src/hooks/lib/inference-events.ts +56 -0
  27. package/src/hooks/lib/inference.ts +17 -1
  28. package/src/hooks/lib/interaction.ts +74 -27
  29. package/src/hooks/lib/opencode-events.ts +59 -0
  30. package/src/hooks/lib/paths.ts +1 -0
  31. package/src/hooks/lib/post-tool.ts +66 -0
  32. package/src/hooks/lib/session-events.ts +211 -0
  33. package/src/hooks/lib/settings.ts +6 -0
  34. package/src/hooks/lib/stop.ts +27 -31
  35. package/src/hooks/lib/tool-events.ts +106 -0
  36. package/src/hooks/lib/transcript-preserve.ts +133 -0
  37. package/src/hooks/lib/turn-extras.ts +78 -0
  38. package/src/hooks/lib/waiting-on-user.ts +75 -0
  39. package/src/hooks/lib/work-tracking.ts +17 -3
  40. package/src/targets/opencode/plugin.ts +33 -1
  41. package/src/tools/agent/interaction.ts +2 -2
  42. package/src/tools/agent/project.ts +4 -0
  43. package/src/tools/control-room/snooze.ts +2 -0
  44. package/src/tools/control-room/writes.ts +2 -0
  45. package/src/tools/knowledge/lib.ts +5 -0
  46. package/src/tools/lib/session-usage.ts +7 -3
  47. package/src/tools/lib/transcript-usage.ts +14 -0
  48. package/src/tools/lib/usage-buckets.ts +15 -8
  49. package/src/hooks/LedgerCommit.ts +0 -34
  50. package/src/hooks/LedgerUnapplied.ts +0 -47
package/README.md CHANGED
@@ -85,6 +85,8 @@ pal cli status # check your setup
85
85
  | `pal cli migrate` | Run pending data migrations (non-destructive) |
86
86
  | `pal cli analyze [--actionable]` | Learning analysis: rating trends, failure patterns, graduation candidates |
87
87
  | `pal cli usage` | Summarize token usage and estimated cost |
88
+ | `pal cli content preserve` | Copy every Claude Code transcript, gzipped, into `memory/content/` before Claude Code deletes it (30 days by default). Runs on its own whenever a session closes; the content store never leaves the machine, so `export` skips it |
89
+ | `pal cli events discord-nag <sent\|answered>` | For a bot outside PAL: record a nag it sent (`--kind <kind>`), or the answer to one and how long it took (`--after-sec <n>`), in the local event log |
88
90
  | `pal cli infer` | One model call for scripts and bots, on the active agent's route: prompt on stdin, reply on stdout. `--tier small\|medium`, `--system <file>`, `--schema <file>` for a JSON reply checked against it, `--timeout <seconds>`. The call loads no PAL hooks or context; exits 1 with the reason when no route answers |
89
91
  | `pal cli actor [label <name>]` | Show or rename the actor — who caused a record. Travels with an export, so a shared memory can tell two people apart |
90
92
  | `pal cli machine [label <name>]` | Show or rename this install — where a record was written. Never leaves the machine |
@@ -27,7 +27,7 @@
27
27
  "hooks": [
28
28
  {
29
29
  "type": "command",
30
- "command": "bun run {{PKG_ROOT}}/src/hooks/LedgerCommit.ts --agent=antigravity"
30
+ "command": "bun run {{PKG_ROOT}}/src/hooks/PostToolOrchestrator.ts --agent=antigravity"
31
31
  }
32
32
  ]
33
33
  }
@@ -39,7 +39,7 @@
39
39
  "hooks": [
40
40
  {
41
41
  "type": "command",
42
- "command": "bun run {{PKG_ROOT}}/src/hooks/LedgerCommit.ts --agent=codex"
42
+ "command": "bun run {{PKG_ROOT}}/src/hooks/PostToolOrchestrator.ts --agent=codex"
43
43
  }
44
44
  ]
45
45
  }
@@ -54,6 +54,16 @@
54
54
  ]
55
55
  }
56
56
  ],
57
+ "PreCompact": [
58
+ {
59
+ "hooks": [
60
+ {
61
+ "type": "command",
62
+ "command": "bun run {{PKG_ROOT}}/src/hooks/PreCompactPersist.ts --agent=codex"
63
+ }
64
+ ]
65
+ }
66
+ ],
57
67
  "SessionEnd": [
58
68
  {
59
69
  "hooks": [
@@ -47,15 +47,15 @@
47
47
  "postToolUse": [
48
48
  {
49
49
  "type": "command",
50
- "bash": "bun run {{PKG_ROOT}}/src/hooks/LedgerCommit.ts --agent=copilot",
51
- "powershell": "bun run {{PKG_ROOT}}/src/hooks/LedgerCommit.ts --agent=copilot"
50
+ "bash": "bun run {{PKG_ROOT}}/src/hooks/PostToolOrchestrator.ts --agent=copilot",
51
+ "powershell": "bun run {{PKG_ROOT}}/src/hooks/PostToolOrchestrator.ts --agent=copilot"
52
52
  }
53
53
  ],
54
54
  "postToolUseFailure": [
55
55
  {
56
56
  "type": "command",
57
- "bash": "bun run {{PKG_ROOT}}/src/hooks/LedgerUnapplied.ts --agent=copilot --event=postToolUseFailure",
58
- "powershell": "bun run {{PKG_ROOT}}/src/hooks/LedgerUnapplied.ts --agent=copilot --event=postToolUseFailure"
57
+ "bash": "bun run {{PKG_ROOT}}/src/hooks/PostToolOrchestrator.ts --agent=copilot --event=postToolUseFailure",
58
+ "powershell": "bun run {{PKG_ROOT}}/src/hooks/PostToolOrchestrator.ts --agent=copilot --event=postToolUseFailure"
59
59
  }
60
60
  ],
61
61
  "agentStop": [
@@ -65,6 +65,13 @@
65
65
  "powershell": "bun run {{PKG_ROOT}}/src/hooks/StopOrchestrator.ts --agent=copilot"
66
66
  }
67
67
  ],
68
+ "preCompact": [
69
+ {
70
+ "type": "command",
71
+ "bash": "bun run {{PKG_ROOT}}/src/hooks/PreCompactPersist.ts --agent=copilot",
72
+ "powershell": "bun run {{PKG_ROOT}}/src/hooks/PreCompactPersist.ts --agent=copilot"
73
+ }
74
+ ],
68
75
  "sessionEnd": [
69
76
  {
70
77
  "type": "command",
@@ -34,13 +34,13 @@
34
34
  "postToolUse": [
35
35
  {
36
36
  "type": "command",
37
- "command": "bun run {{PKG_ROOT}}/src/hooks/LedgerCommit.ts --agent=cursor"
37
+ "command": "bun run {{PKG_ROOT}}/src/hooks/PostToolOrchestrator.ts --agent=cursor"
38
38
  }
39
39
  ],
40
40
  "postToolUseFailure": [
41
41
  {
42
42
  "type": "command",
43
- "command": "bun run {{PKG_ROOT}}/src/hooks/LedgerUnapplied.ts --agent=cursor"
43
+ "command": "bun run {{PKG_ROOT}}/src/hooks/PostToolOrchestrator.ts --agent=cursor"
44
44
  }
45
45
  ],
46
46
  "beforeShellExecution": [
@@ -61,6 +61,12 @@
61
61
  "command": "bun run {{PKG_ROOT}}/src/hooks/StopOrchestrator.ts --agent=cursor"
62
62
  }
63
63
  ],
64
+ "preCompact": [
65
+ {
66
+ "type": "command",
67
+ "command": "bun run {{PKG_ROOT}}/src/hooks/PreCompactPersist.ts --agent=cursor"
68
+ }
69
+ ],
64
70
  "sessionEnd": [
65
71
  {
66
72
  "type": "command",
@@ -158,7 +158,17 @@
158
158
  "hooks": [
159
159
  {
160
160
  "type": "command",
161
- "command": "bun run {{PKG_ROOT}}/src/hooks/LedgerCommit.ts --agent=claude"
161
+ "command": "bun run {{PKG_ROOT}}/src/hooks/PostToolOrchestrator.ts --agent=claude --only=ledger"
162
+ }
163
+ ]
164
+ },
165
+ {
166
+ "matcher": "",
167
+ "hooks": [
168
+ {
169
+ "type": "command",
170
+ "command": "bun run {{PKG_ROOT}}/src/hooks/PostToolOrchestrator.ts --agent=claude --only=events",
171
+ "async": true
162
172
  }
163
173
  ]
164
174
  }
@@ -169,7 +179,39 @@
169
179
  "hooks": [
170
180
  {
171
181
  "type": "command",
172
- "command": "bun run {{PKG_ROOT}}/src/hooks/LedgerUnapplied.ts --agent=claude"
182
+ "command": "bun run {{PKG_ROOT}}/src/hooks/PostToolOrchestrator.ts --agent=claude --only=ledger"
183
+ }
184
+ ]
185
+ },
186
+ {
187
+ "matcher": "",
188
+ "hooks": [
189
+ {
190
+ "type": "command",
191
+ "command": "bun run {{PKG_ROOT}}/src/hooks/PostToolOrchestrator.ts --agent=claude --only=events",
192
+ "async": true
193
+ }
194
+ ]
195
+ }
196
+ ],
197
+ "PermissionRequest": [
198
+ {
199
+ "matcher": "",
200
+ "hooks": [
201
+ {
202
+ "type": "command",
203
+ "command": "bun run {{PKG_ROOT}}/src/hooks/AttentionEvent.ts --agent=claude"
204
+ }
205
+ ]
206
+ }
207
+ ],
208
+ "Notification": [
209
+ {
210
+ "matcher": "",
211
+ "hooks": [
212
+ {
213
+ "type": "command",
214
+ "command": "bun run {{PKG_ROOT}}/src/hooks/AttentionEvent.ts --agent=claude"
173
215
  }
174
216
  ]
175
217
  }
@@ -180,7 +222,17 @@
180
222
  "hooks": [
181
223
  {
182
224
  "type": "command",
183
- "command": "bun run {{PKG_ROOT}}/src/hooks/LedgerUnapplied.ts --agent=claude"
225
+ "command": "bun run {{PKG_ROOT}}/src/hooks/PostToolOrchestrator.ts --agent=claude --only=ledger"
226
+ }
227
+ ]
228
+ },
229
+ {
230
+ "matcher": "",
231
+ "hooks": [
232
+ {
233
+ "type": "command",
234
+ "command": "bun run {{PKG_ROOT}}/src/hooks/PostToolOrchestrator.ts --agent=claude --only=events",
235
+ "async": true
184
236
  }
185
237
  ]
186
238
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portable-agent-layer",
3
- "version": "0.94.0",
3
+ "version": "0.95.1",
4
4
  "description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,23 @@
1
+ import { preserveTranscriptsOnce } from "../hooks/lib/transcript-preserve";
2
+ import { group, leaf } from "../tools/lib/command";
3
+
4
+ function printPreserve(): undefined {
5
+ const result = preserveTranscriptsOnce();
6
+ if (!result) {
7
+ console.log("Another copy is already running.");
8
+ return;
9
+ }
10
+ console.log(
11
+ `Copied ${result.copied}, unchanged ${result.unchanged}, failed ${result.failed}`
12
+ );
13
+ }
14
+
15
+ export const contentCommand = group({
16
+ summary: "PAL's local content store (never exported)",
17
+ commands: {
18
+ preserve: leaf({
19
+ summary: "Copy Claude Code transcripts, gzipped, before Claude Code deletes them",
20
+ run: printPreserve,
21
+ }),
22
+ },
23
+ });
@@ -0,0 +1,47 @@
1
+ import { isNagStage, logDiscordNag, NAG_STAGES } from "../hooks/lib/discord-events";
2
+ import { group, leaf, UsageError } from "../tools/lib/command";
3
+
4
+ function secondsFrom(raw: string | undefined): number | null {
5
+ if (raw === undefined) return null;
6
+ const seconds = Number(raw);
7
+ if (!Number.isFinite(seconds) || seconds < 0) {
8
+ throw new UsageError(`--after-sec must be a number of seconds, got "${raw}"`);
9
+ }
10
+ return Math.round(seconds);
11
+ }
12
+
13
+ function recordDiscordNag(
14
+ stage: string | undefined,
15
+ kind: string | undefined,
16
+ afterSec: string | undefined
17
+ ): undefined {
18
+ if (!isNagStage(stage)) {
19
+ throw new UsageError(`the stage is one of ${NAG_STAGES.join(", ")}`);
20
+ }
21
+ const responseSec = secondsFrom(afterSec);
22
+ if (stage === "answered" && responseSec === null) {
23
+ throw new UsageError("an answered nag needs --after-sec");
24
+ }
25
+ const id = logDiscordNag(stage, kind ?? null, responseSec);
26
+ console.log(id ? `Recorded ${id}` : "The event log is off; nothing recorded.");
27
+ }
28
+
29
+ export const eventsCommand = group({
30
+ summary: "PAL's local event log",
31
+ commands: {
32
+ "discord-nag": leaf({
33
+ summary: "Record a nag the Discord bot sent, or how long its answer took",
34
+ args: "<sent|answered>",
35
+ options: {
36
+ kind: { type: "string", value: "<kind>", description: "What the nag was about" },
37
+ "after-sec": {
38
+ type: "string",
39
+ value: "<n>",
40
+ description: "Seconds from the nag to the answer",
41
+ },
42
+ },
43
+ run: ({ positionals, values }) =>
44
+ recordDiscordNag(positionals[0], values.kind, values["after-sec"]),
45
+ }),
46
+ },
47
+ });
package/src/cli/tree.ts CHANGED
@@ -14,6 +14,8 @@ import {
14
14
  type OptionSpecs,
15
15
  } from "../tools/lib/command";
16
16
  import { builtinTools, builtinToolVerbs } from "./builtin-tools";
17
+ import { contentCommand } from "./content";
18
+ import { eventsCommand } from "./events";
17
19
  import { identityCommand } from "./identity";
18
20
  import { inferCommand } from "./infer";
19
21
  import { knowledgeCommand } from "./knowledge";
@@ -151,6 +153,8 @@ export function cliTree(admin: AdminHandlers): Group {
151
153
  commands: {
152
154
  ...adminCommands(admin),
153
155
  usage: usageCommand,
156
+ content: contentCommand,
157
+ events: eventsCommand,
154
158
  infer: inferCommand,
155
159
  actor: identityCommand("actor"),
156
160
  machine: identityCommand("machine"),
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Hook: PermissionRequest and Notification — logs that the agent is waiting on
3
+ * the user. Logging only: it prints nothing, so it never answers the prompt, and
4
+ * always exits 0.
5
+ */
6
+
7
+ import { logAttention } from "./lib/attention-events";
8
+ import { duplicatesCursorHooks } from "./lib/cursor-shadow";
9
+ import { enterHookWorkspace } from "./lib/hook-turn";
10
+ import { isPalSpawnedInference } from "./lib/spawn-guard";
11
+ import { readStdinJSON } from "./lib/stdin";
12
+
13
+ if (isPalSpawnedInference()) process.exit(0);
14
+ if (duplicatesCursorHooks()) process.exit(0);
15
+
16
+ try {
17
+ const input = await readStdinJSON<Record<string, unknown>>();
18
+ enterHookWorkspace(input);
19
+ logAttention(input);
20
+ } catch {
21
+ process.exit(0);
22
+ }
@@ -24,11 +24,10 @@ import {
24
24
  isSubagentSession,
25
25
  needsAgentsMd,
26
26
  } from "./lib/session-context";
27
+ import { logSessionStart } from "./lib/session-events";
27
28
  import { isPalSpawnedInference } from "./lib/spawn-guard";
28
29
  import { readStdinJSON } from "./lib/stdin";
29
30
 
30
- // Recursion guard — when this process is a PAL-spawned inference subprocess,
31
- // skip all context loading so we don't trigger another inference call.
32
31
  if (isPalSpawnedInference()) process.exit(0);
33
32
  if (duplicatesCursorHooks()) process.exit(0);
34
33
  if (isSubagentSession(process.env)) {
@@ -51,6 +50,7 @@ try {
51
50
  try {
52
51
  const active = getActiveAgent();
53
52
  const input = await readStdinJSON<{ source?: string }>();
53
+ logSessionStart(input);
54
54
  const wanted = contextForSource(active, input?.source);
55
55
  if (wanted === "none") {
56
56
  logDebug("LoadContext", `source=${input?.source}, context already present`);
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Hook: after a tool call, a failed call or a denied one. Silent and fail-open:
3
+ * it prints nothing and always exits 0, so it can never disturb a turn.
4
+ */
5
+
6
+ import { duplicatesCursorHooks } from "./lib/cursor-shadow";
7
+ import { enterHookWorkspace } from "./lib/hook-turn";
8
+ import { runPostToolHandlers } from "./lib/post-tool";
9
+ import { readStdinJSON } from "./lib/stdin";
10
+
11
+ if (duplicatesCursorHooks()) process.exit(0);
12
+
13
+ try {
14
+ const input = await readStdinJSON<Record<string, unknown>>();
15
+ if (!input) process.exit(0);
16
+ enterHookWorkspace(input);
17
+ runPostToolHandlers(input);
18
+ } catch {
19
+ process.exit(0);
20
+ }
@@ -7,8 +7,8 @@
7
7
  *
8
8
  * Storage: ~/.pal/memory/state/last-exchange/{session_id}.json + latest.json.
9
9
  * Path lives under PAL_HOME so other agents (OpenCode, Copilot, Cursor) can read the
10
- * same state if/when they grow equivalent compaction-recovery hooks. PreCompact itself
11
- * is currently a Claude Code event.
10
+ * same state if/when they grow equivalent compaction-recovery hooks. Cursor, Copilot
11
+ * and Codex run it too, which logs their compactions.
12
12
  */
13
13
 
14
14
  import { existsSync } from "node:fs";
@@ -19,6 +19,7 @@ import { duplicatesCursorHooks } from "./lib/cursor-shadow";
19
19
  import { logDebug, logError } from "./lib/log";
20
20
  import { paths } from "./lib/paths";
21
21
  import { sessionDir } from "./lib/session-dir";
22
+ import { logCompaction } from "./lib/session-events";
22
23
  import { isPalSpawnedInference } from "./lib/spawn-guard";
23
24
  import { readStdinJSON } from "./lib/stdin";
24
25
  import { readTranscriptFile } from "./lib/transcript";
@@ -37,6 +38,7 @@ interface PreCompactInput {
37
38
 
38
39
  const main = async () => {
39
40
  const input = await readStdinJSON<PreCompactInput>();
41
+ logCompaction(input);
40
42
  if (!input?.transcript_path) {
41
43
  logDebug("PreCompactPersist", "No transcript_path in input — skipping");
42
44
  process.exit(0);
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Spawned, never awaited — copies Claude Code transcripts into PAL's content
3
+ * store in a process of its own, so no session waits on the gzip.
4
+ */
5
+
6
+ import { logError } from "./lib/log";
7
+ import { preserveTranscriptsOnce } from "./lib/transcript-preserve";
8
+
9
+ try {
10
+ preserveTranscriptsOnce();
11
+ } catch (err) {
12
+ logError("PreserveTranscripts", err);
13
+ }
@@ -13,14 +13,19 @@
13
13
  import { autoUpdateOnClose } from "./lib/auto-update";
14
14
  import { duplicatesCursorHooks } from "./lib/cursor-shadow";
15
15
  import { logError } from "./lib/log";
16
+ import { logSessionEnd } from "./lib/session-events";
16
17
  import { isPalSpawnedInference } from "./lib/spawn-guard";
17
18
  import { readStdinJSON } from "./lib/stdin";
19
+ import { spawnTranscriptPreserve } from "./lib/transcript-preserve";
18
20
 
19
21
  if (isPalSpawnedInference()) process.exit(0);
20
22
  if (duplicatesCursorHooks()) process.exit(0);
21
23
 
24
+ spawnTranscriptPreserve();
25
+
22
26
  try {
23
27
  const input = await readStdinJSON<{ reason?: string }>();
28
+ logSessionEnd(input);
24
29
  autoUpdateOnClose(input?.reason);
25
30
  } catch (err) {
26
31
  logError("SessionClose", err);
@@ -9,6 +9,7 @@
9
9
 
10
10
  import { injectPromptContext } from "./handlers/inject-retrieval";
11
11
  import { duplicatesCursorHooks } from "./lib/cursor-shadow";
12
+ import { startHookTiming } from "./lib/hook-timing";
12
13
  import { type HookTurnPayload, hookSessionId } from "./lib/hook-turn";
13
14
  import { logDebug, logPromptSnapshot } from "./lib/log";
14
15
  import { capturePrompt } from "./lib/prompt-capture";
@@ -28,9 +29,15 @@ const input = await readStdinJSON<PromptSubmitInput>();
28
29
  logDebug("UserPromptOrchestrator", `Input: ${JSON.stringify(input).slice(0, 200)}`);
29
30
  if (!input?.prompt) process.exit(0);
30
31
 
32
+ const timing = startHookTiming("prompt");
31
33
  const sentAt = new Date();
32
34
  const sessionId = hookSessionId(input);
33
- const injected = await injectPromptContext(input.prompt, sessionId);
35
+ const injected = await timing.time("inject-context", () =>
36
+ injectPromptContext(input.prompt, sessionId)
37
+ );
34
38
  logPromptSnapshot(input.prompt, injected);
35
39
 
36
- await capturePrompt(input.prompt, sessionId, sentAt, "UserPromptOrchestrator");
40
+ await timing.time("capture", () =>
41
+ capturePrompt(input.prompt, sessionId, sentAt, "UserPromptOrchestrator")
42
+ );
43
+ timing.log(sessionId);
@@ -0,0 +1,43 @@
1
+ /**
2
+ * When the agent waits on the user: a permission prompt, or a notification that
3
+ * it needs them.
4
+ */
5
+
6
+ import { normalizeToolUse } from "./agent";
7
+ import { appendEvent, logQuietly } from "./events";
8
+ import { hookSessionId } from "./hook-turn";
9
+ import { markWaiting } from "./waiting-on-user";
10
+
11
+ function logPermissionPrompt(
12
+ payload: Record<string, unknown> | null,
13
+ tool: string | null,
14
+ now: Date = new Date()
15
+ ): void {
16
+ logQuietly("permission_prompt", () => {
17
+ const session = hookSessionId(payload) ?? null;
18
+ appendEvent("permission_prompt", { tool }, { ts: now.toISOString(), session });
19
+ if (session)
20
+ markWaiting("permission", session, { ts: now.toISOString(), tool: tool ?? "" });
21
+ });
22
+ }
23
+
24
+ function logNotification(payload: Record<string, unknown> | null, now: Date): void {
25
+ logQuietly("notification", () => {
26
+ const session = hookSessionId(payload) ?? null;
27
+ const kind =
28
+ typeof payload?.notification_type === "string" ? payload.notification_type : null;
29
+ appendEvent("notification", { kind }, { ts: now.toISOString(), session });
30
+ if (session) markWaiting("notified", session, { ts: now.toISOString() });
31
+ });
32
+ }
33
+
34
+ /** Claude's PermissionRequest and Notification share one hook; it prints nothing. */
35
+ export function logAttention(
36
+ payload: Record<string, unknown> | null,
37
+ now: Date = new Date()
38
+ ): void {
39
+ const event = payload?.hook_event_name ?? payload?.hookEventName;
40
+ if (event === "Notification") logNotification(payload, now);
41
+ if (event === "PermissionRequest")
42
+ logPermissionPrompt(payload, normalizeToolUse(payload)?.toolName ?? null, now);
43
+ }
@@ -15,6 +15,7 @@ import { spawn, spawnSync } from "node:child_process";
15
15
  import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
16
16
  import { resolve } from "node:path";
17
17
  import { cachedStatus, getInstalledVersion, isRepoMode } from "../handlers/update-check";
18
+ import { appendEvent, logQuietly } from "./events";
18
19
  import { logDebug, logError } from "./log";
19
20
  import { assets, palPkg, paths } from "./paths";
20
21
  import { raw as rawSettings } from "./settings";
@@ -118,8 +119,23 @@ function hasUncommittedChanges(): boolean {
118
119
  return (status.stdout ?? "").trim().length > 0;
119
120
  }
120
121
 
122
+ function outcomeOf(entry: AutoUpdateLedger): string {
123
+ if (!entry.ok) return "failed";
124
+ return entry.from === entry.to ? "unchanged" : "updated";
125
+ }
126
+
127
+ function secondsBetween(from?: string, to?: string): number | null {
128
+ if (!from || !to) return null;
129
+ return Math.round((Date.parse(to) - Date.parse(from)) / 1000);
130
+ }
131
+
132
+ function logUpdateEvent(fields: Record<string, unknown>): void {
133
+ logQuietly("pal_update", () => appendEvent("pal_update", fields));
134
+ }
135
+
121
136
  function recordSkip(reason: string): void {
122
137
  writeLedger({ ...readLedger(), skippedAt: new Date().toISOString(), skipped: reason });
138
+ logUpdateEvent({ outcome: "skipped", skipReason: reason });
123
139
  logDebug("auto-update", `skipped: ${reason}`);
124
140
  }
125
141
 
@@ -278,7 +294,7 @@ function update(): AutoUpdateLedger {
278
294
 
279
295
  const ok = run.status === 0;
280
296
  logDebug("auto-update", `update exited ${run.status}`);
281
- return writeLedger({
297
+ const entry = writeLedger({
282
298
  ...readLedger(),
283
299
  finishedAt: new Date().toISOString(),
284
300
  ok,
@@ -286,6 +302,13 @@ function update(): AutoUpdateLedger {
286
302
  to: getInstalledVersion(),
287
303
  error: ok ? undefined : (run.error?.message ?? `exit ${run.status}`),
288
304
  });
305
+ logUpdateEvent({
306
+ outcome: outcomeOf(entry),
307
+ from: entry.from ?? null,
308
+ to: entry.to ?? null,
309
+ durationSec: secondsBetween(entry.attemptedAt, entry.finishedAt),
310
+ });
311
+ return entry;
289
312
  }
290
313
 
291
314
  /** Hands the update to its own process, which outlives the session that closed. */
@@ -0,0 +1,20 @@
1
+ /**
2
+ * A commitment is an ISC: completing it keeps it, retiring it drops it, and a
3
+ * snooze puts it off. Each change of state is one event, whichever surface made it.
4
+ */
5
+
6
+ import { appendEvent, logQuietly } from "./events";
7
+
8
+ export type CommitmentOutcome = "kept" | "dropped" | "snoozed" | "reopened";
9
+
10
+ export function logCommitment(
11
+ iscProject: string,
12
+ isc: number,
13
+ outcome: CommitmentOutcome,
14
+ surface: "cli" | "control-room",
15
+ snoozeDays: number | null = null
16
+ ): void {
17
+ logQuietly("commitment", () =>
18
+ appendEvent("commitment", { iscProject, isc, outcome, surface, snoozeDays })
19
+ );
20
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * A nag the Discord bot sent, and how long the user took to answer it. The bot
3
+ * runs outside PAL, so it reports both through `pal cli events discord-nag`.
4
+ */
5
+
6
+ import { appendEvent } from "./events";
7
+
8
+ export const NAG_STAGES = ["sent", "answered"] as const;
9
+ export type NagStage = (typeof NAG_STAGES)[number];
10
+
11
+ export function isNagStage(value: unknown): value is NagStage {
12
+ return typeof value === "string" && (NAG_STAGES as readonly string[]).includes(value);
13
+ }
14
+
15
+ export function logDiscordNag(
16
+ stage: NagStage,
17
+ kind: string | null,
18
+ responseSec: number | null
19
+ ): string | null {
20
+ return appendEvent("discord_nag", {
21
+ stage,
22
+ kind,
23
+ responseSec: stage === "answered" ? responseSec : null,
24
+ });
25
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The first time a person, company, idea or research topic enters the knowledge
3
+ * store. The name stays in the content store; the event carries only its kind.
4
+ */
5
+
6
+ import { appendEvent, logQuietly } from "./events";
7
+
8
+ export function logEntityFirstSeen(
9
+ domain: string,
10
+ entityType: string,
11
+ name: string
12
+ ): void {
13
+ logQuietly("entity_first_seen", () =>
14
+ appendEvent("entity_first_seen", { domain, entityType }, { content: { name } })
15
+ );
16
+ }