portable-agent-layer 0.78.2 → 0.79.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 (36) hide show
  1. package/README.md +3 -2
  2. package/assets/schema/pal-settings.schema.json +0 -1
  3. package/assets/skills/pal-reflect/SKILL.md +2 -2
  4. package/assets/templates/PAL/SYSTEM_ARCHITECTURE.md +4 -4
  5. package/assets/templates/pal-settings.json +0 -1
  6. package/package.json +2 -2
  7. package/src/cli/builtin-tools.ts +1 -0
  8. package/src/cli/index.ts +3 -48
  9. package/src/cli/setup-auto-update.ts +7 -7
  10. package/src/hooks/AutoUpdate.ts +1 -1
  11. package/src/hooks/LoadContext.ts +13 -1
  12. package/src/hooks/PreCompactPersist.ts +2 -2
  13. package/src/hooks/handlers/persist-last-exchange.ts +128 -33
  14. package/src/hooks/handlers/session-intelligence.ts +53 -14
  15. package/src/hooks/lib/algorithm-review.ts +12 -7
  16. package/src/hooks/lib/auto-update.ts +16 -6
  17. package/src/hooks/lib/context.ts +18 -56
  18. package/src/hooks/lib/graduation.ts +2 -2
  19. package/src/hooks/lib/handoff-context.ts +112 -0
  20. package/src/hooks/lib/inference.ts +12 -45
  21. package/src/hooks/lib/log.ts +44 -3
  22. package/src/hooks/lib/paths.ts +7 -2
  23. package/src/hooks/lib/projects.ts +16 -4
  24. package/src/hooks/lib/relationship.ts +2 -2
  25. package/src/hooks/lib/retrieval-index.ts +13 -6
  26. package/src/hooks/lib/security.ts +29 -3
  27. package/src/hooks/lib/semi-static.ts +10 -3
  28. package/src/hooks/lib/session-context.ts +22 -0
  29. package/src/hooks/lib/session-dir.ts +9 -0
  30. package/src/hooks/lib/settings.ts +11 -12
  31. package/src/hooks/lib/work-tracking.ts +9 -1
  32. package/src/tools/{relationship-reflect.ts → agent/relationship-reflect.ts} +13 -12
  33. package/src/tools/control-room/ui/dist/assets/{index-DyhnNmdi.js → index-D_o9Usd_.js} +1 -1
  34. package/src/tools/control-room/ui/dist/index.html +1 -1
  35. package/src/tools/control-room/ui/screens/settings.tsx +2 -2
  36. package/src/tools/self-model.ts +1 -0
package/README.md CHANGED
@@ -77,7 +77,7 @@ pal cli status # check your setup
77
77
  | `pal cli init` | Scaffold PAL home directory and install hooks |
78
78
  | `pal cli install` | Register hooks/skills for targets |
79
79
  | `pal cli uninstall` | Remove hooks/skills for targets |
80
- | `pal cli update` | Update PAL (git pull or npm update) and reinstall hooks. Install asks once whether to do this daily on its own, applied when a session closes so opening PAL is never slowed; the switch lives in the control room under Settings → Updates. On a repo install a daily run waits while the clone has uncommitted changes |
80
+ | `pal cli update` | Update PAL (git pull or npm update) and reinstall hooks. Install asks once whether to do this on its own: when a session closes PAL checks for a release (at most hourly) and installs it, so opening PAL is never slowed. A failed update waits a day before retrying. The switch lives in the control room under Settings → Updates. On a repo install an update waits while the clone has uncommitted changes |
81
81
  | `pal cli export` | Export user state (telos, memory) to a zip |
82
82
  | `pal cli import` | Import user state from a zip |
83
83
  | `pal cli status` | Show current PAL configuration |
@@ -156,7 +156,7 @@ PAL routes inference through the host agent's subscription CLI by default. API k
156
156
  Enable verbose hook logging with:
157
157
 
158
158
  ```
159
- pal cli debug on # enable → logs to memory/state/debug.log
159
+ pal cli debug on # enable → logs to debug/debug.log
160
160
  pal cli debug off # disable
161
161
  pal cli debug # show current status and log path
162
162
  ```
@@ -165,6 +165,7 @@ pal cli debug # show current status and log path
165
165
  |----------|-------------|
166
166
  | `PAL_INFERENCE_DISABLED` | Set to `1` to disable all inference (used by the test suite to prevent real CLI spawns) |
167
167
  | `PAL_NOTIFICATIONS_DISABLED` | Set to `1` to suppress desktop notifications (used by the test suite) |
168
+ | `PAL_TEST_SANDBOX` | Set by the test suite. Without a `PAL_HOME`, state goes to a temp directory instead of `~/.pal`, and installers refuse to touch real agent directories |
168
169
 
169
170
  ---
170
171
 
@@ -57,7 +57,6 @@
57
57
  "wisdom": { "type": "boolean", "description": "Crystallized wisdom principles." },
58
58
  "opinions": { "type": "boolean", "description": "Tracked user opinions." },
59
59
  "relationship": { "type": "boolean", "description": "Recent relationship notes." },
60
- "learningDigest": { "type": "boolean", "description": "Lessons-from-failures digest." },
61
60
  "failurePatterns": { "type": "boolean", "description": "Recurring failure patterns." },
62
61
  "projectHistory": { "type": "boolean", "description": "Per-project session history." },
63
62
  "projects": { "type": "boolean", "description": "Active projects list." },
@@ -19,10 +19,10 @@ When `/pal-reflect` is invoked (by you in response to a nudge, or by the user di
19
19
  ## 1. Run the reflect tool
20
20
 
21
21
  ```bash
22
- bun run tool:reflect
22
+ pal cli relationship-reflect
23
23
  ```
24
24
 
25
- If the user passed `--dry-run`, append it: `bun run tool:reflect -- --dry-run`
25
+ If the user passed `--dry-run`, append it: `pal cli relationship-reflect --dry-run`
26
26
 
27
27
  ## 2. Parse and present the output
28
28
 
@@ -383,7 +383,7 @@ Relationship notes (O/B types)
383
383
  - Content is global/user-level — safe to pre-compile (not project-scoped)
384
384
 
385
385
  **Tier 3 — Dynamic** (injected fresh each session by LoadContext hook):
386
- - Handoff notes, session intelligence, open threads, relationship notes, project history
386
+ - Handoff notes, session intelligence, relationship notes, project history
387
387
  - Changes per-session or is project-scoped — can't be pre-compiled
388
388
  - Injected as `<system-reminder>` block via stdout
389
389
  - Each section independently toggleable in `pal-settings.json → dynamicContext`
@@ -402,12 +402,12 @@ LoadContext.ts
402
402
  └─► Build system-reminder (dynamic sections only):
403
403
  1. loadAtStartup files (user-configured)
404
404
  2. Handoff note (in-progress work from last session)
405
- 3. Session intelligence (rating trend, algorithm performance)
406
- 4. Open threads (current project only)
405
+ 3. Due nudges (algorithm review, relationship reflect, learning analysis)
406
+ 4. Session intelligence (rating trend, algorithm performance)
407
407
  5. Recent interaction notes (last 2 days)
408
408
  6. Active projects
409
409
  7. Project session history (this project)
410
- 8. Signal trends (today/week/trend)
410
+ 8. Other recent learnings (other projects, titles only)
411
411
  ```
412
412
 
413
413
  ### On-Demand Context
@@ -19,7 +19,6 @@
19
19
  "wisdom": true,
20
20
  "opinions": true,
21
21
  "relationship": true,
22
- "learningDigest": true,
23
22
  "failurePatterns": true,
24
23
  "projectHistory": true,
25
24
  "projects": true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portable-agent-layer",
3
- "version": "0.78.2",
3
+ "version": "0.79.0",
4
4
  "description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
5
5
  "type": "module",
6
6
  "bin": {
@@ -64,7 +64,7 @@
64
64
  "tool:synthesize": "bun run src/tools/agent/synthesize.ts",
65
65
  "tool:thread": "bun run src/tools/agent/thread.ts",
66
66
  "tool:wisdom-frame": "bun run src/tools/agent/wisdom-frame.ts",
67
- "tool:reflect": "bun run src/tools/relationship-reflect.ts",
67
+ "tool:reflect": "bun run src/tools/agent/relationship-reflect.ts",
68
68
  "eval:sentiment": "bun eval/run.ts sentiment",
69
69
  "eval:failure-principle": "bun eval/run.ts failure-principle",
70
70
  "eval:goal-links": "bun eval/run.ts goal-links",
@@ -13,6 +13,7 @@ const BUILTIN_TOOLS = {
13
13
  "handoff-note": () => import("../tools/agent/handoff-note"),
14
14
  project: () => import("../tools/agent/project"),
15
15
  "relationship-note": () => import("../tools/agent/relationship-note"),
16
+ "relationship-reflect": () => import("../tools/agent/relationship-reflect"),
16
17
  synthesize: () => import("../tools/agent/synthesize"),
17
18
  thread: () => import("../tools/agent/thread"),
18
19
  "wisdom-frame": () => import("../tools/agent/wisdom-frame"),
package/src/cli/index.ts CHANGED
@@ -47,7 +47,7 @@ import {
47
47
  summarize,
48
48
  } from "../hooks/lib/import-merge";
49
49
  import { inference, previewInferenceRoute } from "../hooks/lib/inference";
50
- import { DEBUG_LOG_MAX_ROTATED, logDebug } from "../hooks/lib/log";
50
+ import { logDebug, recentHookErrors } from "../hooks/lib/log";
51
51
  import { ensureRegistered, writeRegistryEntry } from "../hooks/lib/machine";
52
52
  import { palHome, palPkg, paths, platform, toPath } from "../hooks/lib/paths";
53
53
  import { auditBindings, describeBindingIssue } from "../hooks/lib/projects";
@@ -462,11 +462,6 @@ function resolveTargets(args: string[], health?: DoctorResult): Targets {
462
462
 
463
463
  // ── Hook health ──
464
464
 
465
- interface HookHealth {
466
- totalErrors: number;
467
- lastError: string | null;
468
- }
469
-
470
465
  function checkClaudeHooksRegistered(): boolean {
471
466
  const settingsPath = resolve(platform.claudeDir(), "settings.json");
472
467
  if (!existsSync(settingsPath)) return false;
@@ -645,7 +640,7 @@ async function probeInference(): Promise<void> {
645
640
  );
646
641
  } else {
647
642
  console.log(
648
- ` ${red}✗${reset} ${tag} ${String(elapsedMs).padStart(6)}ms ${dim}failed — see ~/.pal/memory/state/debug.log${reset}`
643
+ ` ${red}✗${reset} ${tag} ${String(elapsedMs).padStart(6)}ms ${dim}failed — see ~/.pal/debug/debug.log${reset}`
649
644
  );
650
645
  }
651
646
  }
@@ -733,46 +728,6 @@ function rtkInstallHint(): string {
733
728
  return "`curl -fsSL https://raw.githubusercontent.com/rtk-ai/rtk/refs/heads/master/install.sh | sh`";
734
729
  }
735
730
 
736
- function checkHookHealth(home: string): HookHealth {
737
- const stateDir = resolve(home, "memory", "state");
738
- // Read current + all rotated logs (`.1`..`.5`) + legacy `.prev` so a recent
739
- // rotation doesn't make the 24h window appear empty.
740
- const candidates = [
741
- resolve(stateDir, "debug.log"),
742
- resolve(stateDir, "debug.log.prev"),
743
- ...Array.from({ length: DEBUG_LOG_MAX_ROTATED }, (_, i) =>
744
- resolve(stateDir, `debug.log.${i + 1}`)
745
- ),
746
- ];
747
-
748
- try {
749
- let content = "";
750
- for (const path of candidates) {
751
- if (existsSync(path)) content += `${readFileSync(path, "utf-8")}\n`;
752
- }
753
- if (!content) return { totalErrors: 0, lastError: null };
754
-
755
- const lines = content.split("\n").filter((l) => l.includes("] ERROR "));
756
-
757
- // Filter to last 24h
758
- const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000);
759
- const recentErrors = lines.filter((line) => {
760
- const match = new RegExp(/^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\]/).exec(line);
761
- if (!match) return false;
762
- return new Date(match[1]) > cutoff;
763
- });
764
-
765
- const lastError =
766
- recentErrors.length > 0
767
- ? (recentErrors.at(-1) ?? "").replace(/^\[.*?\] ERROR /, "").slice(0, 120)
768
- : null;
769
-
770
- return { totalErrors: recentErrors.length, lastError };
771
- } catch {
772
- return { totalErrors: 0, lastError: null };
773
- }
774
- }
775
-
776
731
  // ── Doctor ──
777
732
 
778
733
  interface DoctorResult {
@@ -1131,7 +1086,7 @@ function doctor(silent = false): DoctorResult {
1131
1086
  : warn("PAL_PERPLEXITY_API_KEY — not set (optional, for Perplexity researcher)");
1132
1087
 
1133
1088
  // Hook health from debug.log
1134
- const hookHealth = checkHookHealth(home);
1089
+ const hookHealth = recentHookErrors();
1135
1090
  if (hookHealth.totalErrors === 0) {
1136
1091
  ok("Hooks: no recent errors");
1137
1092
  } else {
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Daily unattended updates — one-time opt-in prompt.
2
+ * Unattended updates — one-time opt-in prompt.
3
3
  *
4
4
  * Asked once during `pal install`, the same seam git attribution uses, so a new
5
5
  * user sees it at init and an existing one on their next update. The non-TTY
@@ -14,11 +14,11 @@ import { raw as readSettings, write as writeSettings } from "../hooks/lib/settin
14
14
 
15
15
  /** Only a git clone can be mid-change; a global package install has no such state. */
16
16
  function whatItDoes(): string {
17
- const daily =
18
- "Once a day, when you close a session, PAL updates itself in the background.\nOpening PAL is never slowed down, and the statusline says when to restart.";
17
+ const onClose =
18
+ "When you close a session, PAL checks for a new release (at most hourly) and installs it in the background.\nOpening PAL is never slowed down, and the statusline says when to restart.";
19
19
  return isRepoMode()
20
- ? `${daily}\nIt waits while this clone has uncommitted changes.`
21
- : daily;
20
+ ? `${onClose}\nIt waits while this clone has uncommitted changes.`
21
+ : onClose;
22
22
  }
23
23
 
24
24
  export async function promptAutoUpdate(): Promise<void> {
@@ -31,7 +31,7 @@ export async function promptAutoUpdate(): Promise<void> {
31
31
  clack.note(whatItDoes(), "Keep PAL up to date on its own?");
32
32
 
33
33
  const enabled = await clack.confirm({
34
- message: "Turn on daily automatic updates?",
34
+ message: "Turn on automatic updates?",
35
35
  initialValue: false,
36
36
  });
37
37
  if (clack.isCancel(enabled)) {
@@ -41,6 +41,6 @@ export async function promptAutoUpdate(): Promise<void> {
41
41
 
42
42
  settings.autoUpdate = { enabled: enabled === true, decided: true };
43
43
  writeSettings(settings);
44
- const state = enabled ? "Daily updates on" : "Daily updates off";
44
+ const state = enabled ? "Automatic updates on" : "Automatic updates off";
45
45
  clack.outro(`${state} ✓ · change later: control room → Settings → Updates`);
46
46
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Spawned, never awaited — runs the daily self-update in a process of its own.
2
+ * Spawned, never awaited — runs the unattended self-update in a process of its own.
3
3
  *
4
4
  * LoadContext decides whether today's update is due; the control room's button
5
5
  * starts this directly. Either way the work happens here so nothing waits on a
@@ -18,11 +18,13 @@ import { logContextSnapshot, logDebug, logError } from "./lib/log";
18
18
  import { platform } from "./lib/paths";
19
19
  import {
20
20
  contextEnvelope,
21
+ contextForSource,
21
22
  copilotInstructions,
22
23
  isSubagentSession,
23
24
  needsAgentsMd,
24
25
  } from "./lib/session-context";
25
26
  import { isPalSpawnedInference } from "./lib/spawn-guard";
27
+ import { readStdinJSON } from "./lib/stdin";
26
28
 
27
29
  // Recursion guard — when this process is a PAL-spawned inference subprocess,
28
30
  // skip all context loading so we don't trigger another inference call.
@@ -47,11 +49,21 @@ try {
47
49
 
48
50
  try {
49
51
  const active = getActiveAgent();
52
+ const input = await readStdinJSON<{ source?: string }>();
53
+ const wanted = contextForSource(active, input?.source);
54
+ if (wanted === "none") {
55
+ logDebug("LoadContext", `source=${input?.source}, context already present`);
56
+ process.exit(0);
57
+ }
58
+
50
59
  // The reminder is built for one of three targets; every other agent reads the
51
60
  // same shape Claude Code does.
52
61
  const target: AgentTarget =
53
62
  active === "copilot" || active === "cursor" ? active : "claude";
54
- const reminder = buildSystemReminder({ agent: target });
63
+ const reminder = buildSystemReminder({
64
+ agent: target,
65
+ withoutHandoff: wanted === "without-handoff",
66
+ });
55
67
  if (!reminder) process.exit(0);
56
68
  logContextSnapshot(reminder);
57
69
 
@@ -17,6 +17,7 @@ import { resolve } from "node:path";
17
17
  import { persistLastExchange } from "./handlers/persist-last-exchange";
18
18
  import { logDebug, logError } from "./lib/log";
19
19
  import { paths } from "./lib/paths";
20
+ import { sessionDir } from "./lib/session-dir";
20
21
  import { isPalSpawnedInference } from "./lib/spawn-guard";
21
22
  import { readStdinJSON } from "./lib/stdin";
22
23
  import { readTranscriptFile } from "./lib/transcript";
@@ -27,7 +28,6 @@ if (isPalSpawnedInference()) process.exit(0);
27
28
  interface PreCompactInput {
28
29
  session_id?: string;
29
30
  transcript_path?: string;
30
- cwd?: string;
31
31
  hook_event_name?: string;
32
32
  trigger?: "manual" | "auto";
33
33
  custom_instructions?: string;
@@ -47,7 +47,7 @@ const main = async () => {
47
47
  process.exit(0);
48
48
  }
49
49
  const sessionId = input.session_id ?? "unknown";
50
- const cwd = input.cwd ?? process.cwd();
50
+ const cwd = sessionDir();
51
51
 
52
52
  // Stop fires after every response and is authoritative. Skip if it already
53
53
  // wrote latest.json for this session — PreCompact is a safety net only.
@@ -4,22 +4,22 @@
4
4
  * Writes two outputs:
5
5
  * 1. last-exchange/{sessionId}.json + last-exchange/latest.json
6
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"
7
+ * 2. last-handoff.json keyed by the session's start folder
8
+ * → read by loadHandoffContext() to surface "Pick Up Where You Left Off"
9
9
  *
10
- * last-exchange is always overwritten — Stop is its source of truth. last-handoff
11
- * is NOT: a deliberate LEARN-phase note (written by handoff-note.ts with
12
- * source:"deliberate") outranks this raw auto-snapshot, so we leave it intact
13
- * while it is still fresh and in-progress. Otherwise the auto-snapshot would
14
- * clobber the curated handoff the moment the session stopped (ISC-39).
10
+ * last-exchange is always overwritten — Stop is its source of truth. In
11
+ * last-handoff only the exchange fields are: a deliberate LEARN-phase note
12
+ * (source:"deliberate") and the summary session intelligence wrote for this
13
+ * session both stay beside it (ISC-39).
15
14
  */
16
15
 
17
16
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
18
17
  import { resolve } from "node:path";
18
+ import { trimAtWord } from "../lib/handoff-context";
19
19
  import { logDebug, logError } from "../lib/log";
20
20
  import { ensureDir, paths } from "../lib/paths";
21
+ import { sessionDir } from "../lib/session-dir";
21
22
  import { extractContent, extractLastAssistant, extractLastUser } from "../lib/transcript";
22
- import { detectStatus } from "../lib/work-tracking";
23
23
 
24
24
  type ParsedMessage = { role: string; content: unknown };
25
25
 
@@ -35,10 +35,128 @@ function isProtectedHandoff(entry: unknown): boolean {
35
35
  return Date.now() - new Date(e.timestamp).getTime() <= HANDOFF_STALE_MS;
36
36
  }
37
37
 
38
+ interface AutoHandoff {
39
+ title: string;
40
+ status: string;
41
+ handoff: string;
42
+ waitingOn?: string;
43
+ sessionId?: string;
44
+ }
45
+
46
+ interface LastExchange {
47
+ sessionId: string;
48
+ lastUser: string;
49
+ lastAssistant: string;
50
+ }
51
+
52
+ type StoredHandoff = Partial<AutoHandoff & LastExchange> & {
53
+ timestamp?: string;
54
+ source?: string;
55
+ artifacts?: string[];
56
+ };
57
+
58
+ const LAST_USER_MAX_CHARS = 200;
59
+ const LAST_ASSISTANT_MAX_CHARS = 300;
60
+
61
+ function handoffPath(): string {
62
+ return resolve(ensureDir(paths.state()), "last-handoff.json");
63
+ }
64
+
65
+ function readHandoffs(): Record<string, StoredHandoff> {
66
+ try {
67
+ const p = handoffPath();
68
+ return existsSync(p) ? JSON.parse(readFileSync(p, "utf-8")) : {};
69
+ } catch {
70
+ return {};
71
+ }
72
+ }
73
+
74
+ function writeHandoffs(handoffs: Record<string, StoredHandoff>): void {
75
+ writeFileSync(handoffPath(), JSON.stringify(handoffs, null, 2), "utf-8");
76
+ }
77
+
78
+ export function structuredHandoff(parts: {
79
+ done: string;
80
+ next: string;
81
+ waitingOn: string;
82
+ }): string {
83
+ return [
84
+ parts.done && `Done: ${parts.done}`,
85
+ parts.next && `Next: ${parts.next}`,
86
+ parts.waitingOn && `Waiting on you: ${parts.waitingOn}`,
87
+ ]
88
+ .filter(Boolean)
89
+ .join("\n");
90
+ }
91
+
92
+ function sameSession(entry: StoredHandoff | undefined, sessionId?: string): boolean {
93
+ return sessionId !== undefined && entry?.sessionId === sessionId;
94
+ }
95
+
96
+ /** Record this folder's summary unless a fresh deliberate note already owns it (ISC-39). */
97
+ export function writeAutoHandoff(cwd: string, entry: AutoHandoff): void {
98
+ const handoffs = readHandoffs();
99
+ const existing = handoffs[cwd];
100
+ if (isProtectedHandoff(existing)) return;
101
+ const exchange = sameSession(existing, entry.sessionId)
102
+ ? { lastUser: existing?.lastUser, lastAssistant: existing?.lastAssistant }
103
+ : {};
104
+ handoffs[cwd] = {
105
+ timestamp: new Date().toISOString(),
106
+ ...entry,
107
+ ...exchange,
108
+ artifacts: [],
109
+ source: "auto",
110
+ };
111
+ writeHandoffs(handoffs);
112
+ }
113
+
114
+ function closingParagraph(text: string): string {
115
+ const paragraphs = text
116
+ .split(/\n\s*\n/)
117
+ .map((p) => p.trim())
118
+ .filter((p) => p.length >= 15);
119
+ return paragraphs.at(-1) ?? text;
120
+ }
121
+
122
+ /**
123
+ * The raw exchange is written on every stop; the summary only every few
124
+ * messages. So within one session the exchange is refreshed beside the
125
+ * summary rather than over it, and a new session starts a clean entry.
126
+ */
127
+ function recordLastExchange(cwd: string, exchange: LastExchange): void {
128
+ const handoffs = readHandoffs();
129
+ const existing = handoffs[cwd];
130
+ const trimmed = {
131
+ sessionId: exchange.sessionId,
132
+ lastUser: trimAtWord(exchange.lastUser, LAST_USER_MAX_CHARS),
133
+ lastAssistant: trimAtWord(
134
+ closingParagraph(exchange.lastAssistant),
135
+ LAST_ASSISTANT_MAX_CHARS
136
+ ),
137
+ };
138
+ if (isProtectedHandoff(existing)) {
139
+ handoffs[cwd] = { ...existing, ...trimmed };
140
+ } else if (sameSession(existing, exchange.sessionId)) {
141
+ handoffs[cwd] = { ...existing, ...trimmed, timestamp: new Date().toISOString() };
142
+ } else {
143
+ handoffs[cwd] = {
144
+ timestamp: new Date().toISOString(),
145
+ title: trimAtWord(exchange.lastUser, 80) || "Session",
146
+ status: "in-progress",
147
+ handoff: "",
148
+ artifacts: [],
149
+ source: "auto",
150
+ ...trimmed,
151
+ };
152
+ }
153
+ writeHandoffs(handoffs);
154
+ }
155
+
38
156
  export function persistLastExchange(
39
157
  messages: ParsedMessage[],
40
158
  sessionId: string,
41
- cwd: string = process.cwd()
159
+ cwd: string = sessionDir()
42
160
  ): void {
43
161
  try {
44
162
  const lastUser = extractContent(extractLastUser(messages));
@@ -59,30 +177,7 @@ export function persistLastExchange(
59
177
  writeFileSync(resolve(stateDir, `${sessionId}.json`), json, "utf-8");
60
178
  writeFileSync(resolve(stateDir, "latest.json"), json, "utf-8");
61
179
 
62
- // 2. Write last-handoff.json for "Pick Up Where You Left Off" — unless a
63
- // fresh deliberate note already owns this cwd (ISC-39).
64
- const handoffPath = resolve(paths.state(), "last-handoff.json");
65
- const existing: Record<string, unknown> = existsSync(handoffPath)
66
- ? JSON.parse(readFileSync(handoffPath, "utf-8"))
67
- : {};
68
- if (!isProtectedHandoff(existing[cwd])) {
69
- const title = (lastUser.slice(0, 80).replace(/\n/g, " ") || "Session").trim();
70
- const handoff = [
71
- lastUser ? `Last user message:\n${lastUser.slice(0, 500)}` : "",
72
- lastAssistant ? `\nLast assistant response:\n${lastAssistant.slice(0, 500)}` : "",
73
- ]
74
- .filter(Boolean)
75
- .join("");
76
- existing[cwd] = {
77
- timestamp: new Date().toISOString(),
78
- title,
79
- status: detectStatus(lastAssistant),
80
- handoff,
81
- artifacts: [],
82
- source: "auto",
83
- };
84
- writeFileSync(handoffPath, JSON.stringify(existing, null, 2), "utf-8");
85
- }
180
+ recordLastExchange(cwd, { sessionId, lastUser, lastAssistant });
86
181
 
87
182
  logDebug(
88
183
  "persist-last-exchange",
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Stop handler: unified session intelligence capture.
3
3
  *
4
- * Produces: title, summary, insights via Haiku.
5
- * Writes: session learning file, project history.
4
+ * Produces: title, summary, insights, status and a done/next/waiting handoff via Haiku.
5
+ * Writes: session learning file, project history, auto handoff.
6
6
  *
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
@@ -23,6 +23,7 @@ import { canInfer, inference } from "../lib/inference";
23
23
  import { categorizeLearning } from "../lib/learning-category";
24
24
  import { logDebug, logError } from "../lib/log";
25
25
  import { ensureDir, paths } from "../lib/paths";
26
+ import { sessionDir } from "../lib/session-dir";
26
27
  import { fileTimestamp, monthPath } from "../lib/time";
27
28
  import { logTokenUsage } from "../lib/token-usage";
28
29
  import {
@@ -31,7 +32,8 @@ import {
31
32
  extractLastUser,
32
33
  parseMessages,
33
34
  } from "../lib/transcript";
34
- import { appendProjectHistory, detectStatus } from "../lib/work-tracking";
35
+ import { appendProjectHistory } from "../lib/work-tracking";
36
+ import { structuredHandoff, writeAutoHandoff } from "./persist-last-exchange";
35
37
 
36
38
  // ── JSON schema for merged Haiku call ──
37
39
 
@@ -50,20 +52,51 @@ const INTELLIGENCE_SCHEMA = {
50
52
  description:
51
53
  "What worked, what was surprising, what to do differently, 2-3 bullet points",
52
54
  },
53
- handoff: {
55
+ status: {
54
56
  type: "string" as const,
57
+ enum: ["in-progress", "completed"],
55
58
  description:
56
- "If status is in-progress: what remains to be done, key decisions made, blockers. If completed: empty string.",
59
+ "in-progress if work remains or the AI's last message awaits the user's answer; completed only if nothing is left",
60
+ },
61
+ done: {
62
+ type: "string" as const,
63
+ description: "What the latest piece of work finished, one sentence",
64
+ },
65
+ next: {
66
+ type: "string" as const,
67
+ description: "The concrete next step, one sentence, empty if none",
68
+ },
69
+ waitingOn: {
70
+ type: "string" as const,
71
+ description:
72
+ "What the AI needs from the user before it can continue, empty if nothing",
57
73
  },
58
74
  },
59
- required: ["title", "summary", "insights", "handoff"] as const,
75
+ required: [
76
+ "title",
77
+ "summary",
78
+ "insights",
79
+ "status",
80
+ "done",
81
+ "next",
82
+ "waitingOn",
83
+ ] as const,
60
84
  };
61
85
 
62
86
  interface IntelligenceOutput {
63
87
  title: string;
64
88
  summary: string;
65
89
  insights: string;
66
- handoff: string;
90
+ status: "in-progress" | "completed";
91
+ done: string;
92
+ next: string;
93
+ waitingOn: string;
94
+ }
95
+
96
+ /** A reply states its verdict first and its question to the user last. */
97
+ export function openingAndClosing(text: string, each: number): string {
98
+ if (text.length <= each * 2) return text;
99
+ return `${text.slice(0, each)} … ${text.slice(-each)}`;
67
100
  }
68
101
 
69
102
  // ── Main handler ──
@@ -95,10 +128,9 @@ export async function captureSessionIntelligence(
95
128
  const lastAssistant = extractLastAssistant(messages);
96
129
  const lastAssistantText = extractContent(lastAssistant);
97
130
  const lastUser = extractLastUser(messages);
98
- const status = detectStatus(lastAssistantText);
99
131
 
100
132
  const userWindow = userMessages.slice(-10).map((t) => t.slice(0, 200));
101
- const assistantWindow = lastAssistantText.slice(0, 600);
133
+ const assistantWindow = openingAndClosing(lastAssistantText, 300);
102
134
 
103
135
  if (userWindow.length < 3) return;
104
136
 
@@ -110,17 +142,15 @@ export async function captureSessionIntelligence(
110
142
  const result = await inference({
111
143
  system: [
112
144
  "You analyze a session between a human user and an AI assistant. Sessions may involve coding, research, writing, planning, analysis, or any other task.",
113
- `Session status: ${status}.`,
114
145
  "Produce ALL of the following:",
115
146
  "1. title: short title (5-10 words) describing what was accomplished",
116
147
  "2. summary: what the AI did for the user (2-4 sentences, AI perspective using 'we')",
117
148
  "3. insights: what worked, what was surprising, what to do differently (2-3 points, no markdown)",
118
- status === "in-progress"
119
- ? "4. handoff: what remains unfinished — decisions made so far, next steps, blockers (2-4 sentences)"
120
- : "4. handoff: empty string (session completed)",
149
+ "4. status: in-progress if work remains or the last AI response ends with a question or offer awaiting the user; completed only if nothing is left",
150
+ "5. done, next, waitingOn: a handoff for whoever picks this up next. Describe the LATEST thread of work, the one the last messages are about, not earlier topics of the session. One sentence each; next and waitingOn may be empty.",
121
151
  ].join("\n"),
122
152
  user: `User messages:\n${numberedMessages}\n\nLast AI response:\n${assistantWindow}`,
123
- maxTokens: 350,
153
+ maxTokens: 450,
124
154
  timeout: 90000,
125
155
  jsonSchema: INTELLIGENCE_SCHEMA,
126
156
  caller: "session-intelligence",
@@ -140,6 +170,15 @@ export async function captureSessionIntelligence(
140
170
  const title = output?.title || extractContent(lastUser).slice(0, 80) || "session";
141
171
  const summary = output?.summary || lastAssistantText.slice(0, 600);
142
172
  const insights = output?.insights || "";
173
+ if (output) {
174
+ writeAutoHandoff(sessionDir(), {
175
+ title,
176
+ status: output.status,
177
+ handoff: structuredHandoff(output),
178
+ waitingOn: output.waitingOn,
179
+ sessionId,
180
+ });
181
+ }
143
182
  // ── Write session learning file ──
144
183
 
145
184
  const category = categorizeLearning(title, summary);
@@ -35,11 +35,13 @@ export function writeReviewMark(ts: string): void {
35
35
  writeFileSync(markFile(), `${JSON.stringify({ lastReviewedTs: ts }, null, 2)}\n`);
36
36
  }
37
37
 
38
- /** True only in a maintainer repo checkout — the repo-only update skill is present. */
39
- export function isMaintainerEnv(): boolean {
40
- return existsSync(
41
- resolve(palPkg(), ".agents", "skills", "algorithm-update", "SKILL.md")
42
- );
38
+ function hasAlgorithmUpdateSkill(root: string): boolean {
39
+ return existsSync(resolve(root, ".agents", "skills", "algorithm-update", "SKILL.md"));
40
+ }
41
+
42
+ /** True when PAL runs from its checkout, or a session is working inside one. */
43
+ export function isMaintainerEnv(cwd: string = process.cwd()): boolean {
44
+ return hasAlgorithmUpdateSkill(palPkg()) || hasAlgorithmUpdateSkill(cwd);
43
45
  }
44
46
 
45
47
  /** Count reflections newer than the last review mark (all of them, if never reviewed). */
@@ -60,8 +62,11 @@ export interface ReviewNudge {
60
62
  * Nudge data when BOTH thresholds are met (≥25 new AND ≥7 days) in a maintainer
61
63
  * env, else null. `now` is injected for testability.
62
64
  */
63
- export function algorithmReviewNudge(now: Date): ReviewNudge | null {
64
- if (!isMaintainerEnv()) return null;
65
+ export function algorithmReviewNudge(
66
+ now: Date,
67
+ cwd: string = process.cwd()
68
+ ): ReviewNudge | null {
69
+ if (!isMaintainerEnv(cwd)) return null;
65
70
  const mark = readReviewMark();
66
71
  const count = countUnreviewed(mark);
67
72
  if (count < NUDGE_MIN_COUNT) return null;