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
@@ -18,13 +18,13 @@ import { similarity } from "./text-similarity";
18
18
  export type EvidenceType = "supporting" | "counter" | "confirmation" | "contradiction";
19
19
  export type OpinionCategory = "communication" | "technical" | "workflow" | "general";
20
20
 
21
- export interface Evidence {
21
+ interface Evidence {
22
22
  date: string;
23
23
  type: EvidenceType;
24
24
  source: string;
25
25
  }
26
26
 
27
- export interface Opinion {
27
+ interface Opinion {
28
28
  id: string;
29
29
  statement: string;
30
30
  confidence: number;
@@ -145,7 +145,7 @@ export function createOpinion(statement: string, source: string): Opinion {
145
145
  }
146
146
 
147
147
  /** Check if an opinion already has evidence with this exact source text. */
148
- export function hasEvidence(opinion: Opinion, source: string): boolean {
148
+ function hasEvidence(opinion: Opinion, source: string): boolean {
149
149
  return opinion.evidence.some((e) => e.source === source);
150
150
  }
151
151
 
@@ -54,6 +54,7 @@ export const paths = {
54
54
  projectHistory: () => ensureDir(home("memory", "projects")),
55
55
  sessionLearning: () => ensureDir(home("memory", "learning", "session")),
56
56
  synthesis: () => ensureDir(home("memory", "learning", "synthesis")),
57
+ work: () => ensureDir(home("memory", "work")),
57
58
  backups: () => ensureDir(home("backups")),
58
59
  } as const;
59
60
 
@@ -78,6 +79,7 @@ export const assets = {
78
79
  claudeSettingsTemplate: () => pkg("assets", "templates", "settings.claude.json"),
79
80
  cursorHooksTemplate: () => pkg("assets", "templates", "hooks.cursor.json"),
80
81
  copilotHooksTemplate: () => pkg("assets", "templates", "hooks.copilot.json"),
82
+ codexHooksTemplate: () => pkg("assets", "templates", "hooks.codex.json"),
81
83
  agentTools: () => pkg("src", "tools", "agent"),
82
84
  palDocs: () => pkg("assets", "templates", "PAL"),
83
85
  } as const;
@@ -1,12 +1,10 @@
1
1
  /**
2
- * Projects — registry of user-curated projects with auto-managed state.
2
+ * Projects — registry of user-curated projects backed by ISA.md files.
3
3
  *
4
- * Each project lives in `~/.pal/memory/state/progress/{slug}.json`. The CLI
5
- * (`src/tools/agent/project.ts`) is the user/AI-facing writer; the Stop hook
4
+ * Each project lives in `~/.pal/memory/projects/{slug}/ISA.md`. Frontmatter
5
+ * holds operational state; the body holds ISA spec sections. The CLI
6
+ * (`src/tools/agent/project.ts`) is the primary writer; the Stop hook
6
7
  * auto-touches `updated` when cwd resolves into a registered project.
7
- *
8
- * Replaces the hand-edited `~/.pal/telos/PROJECTS.md` — see plan
9
- * `~/.claude/plans/clever-frolicking-harp.md` for context.
10
8
  */
11
9
 
12
10
  import {
@@ -14,35 +12,38 @@ import {
14
12
  mkdirSync,
15
13
  readdirSync,
16
14
  readFileSync,
15
+ rmdirSync,
17
16
  unlinkSync,
18
17
  writeFileSync,
19
18
  } from "node:fs";
20
19
  import { basename, dirname, parse as parsePath, resolve, sep } from "node:path";
20
+ import { parse, stringify } from "./frontmatter";
21
21
  import { paths } from "./paths";
22
22
 
23
23
  export type ProjectStatus = "active" | "paused" | "complete" | "archived";
24
24
 
25
- export interface Decision {
26
- ts: string;
27
- decision: string;
28
- rationale: string;
29
- }
30
-
31
25
  export interface ProjectProgress {
32
26
  name: string;
33
27
  path: string;
34
28
  status: ProjectStatus;
35
29
  created: string;
36
30
  updated: string;
37
- facts?: string[];
38
- objectives?: string[];
39
- next_steps?: string[];
31
+ next?: string[];
40
32
  blockers?: string[];
41
33
  handoff?: string;
42
- decisions?: Decision[];
34
+ // ISA body sections
35
+ problem?: string;
36
+ goal?: string;
37
+ criteria?: string;
38
+ vision?: string;
39
+ constraints?: string;
40
+ out_of_scope?: string;
41
+ context?: string;
42
+ decisions?: string;
43
+ changelog?: string;
43
44
  }
44
45
 
45
- export const PROJECT_STALE_DAYS_DEFAULT = 14;
46
+ const PROJECT_STALE_DAYS_DEFAULT = 14;
46
47
 
47
48
  const PROJECT_MARKERS = [
48
49
  ".git",
@@ -54,23 +55,69 @@ const PROJECT_MARKERS = [
54
55
  "Gemfile",
55
56
  ];
56
57
 
57
- function progressDir(): string {
58
- const dir = paths.progress();
59
- mkdirSync(dir, { recursive: true });
60
- return dir;
58
+ type IsaMeta = {
59
+ name: string;
60
+ path: string;
61
+ status: ProjectStatus;
62
+ created: string;
63
+ updated: string;
64
+ next?: string[];
65
+ blockers?: string[];
66
+ handoff?: string;
67
+ };
68
+
69
+ const BODY_SECTIONS: Array<[string, keyof ProjectProgress]> = [
70
+ ["Problem", "problem"],
71
+ ["Goal", "goal"],
72
+ ["Criteria", "criteria"],
73
+ ["Vision", "vision"],
74
+ ["Constraints", "constraints"],
75
+ ["Out of Scope", "out_of_scope"],
76
+ ["Context", "context"],
77
+ ["Decisions", "decisions"],
78
+ ["Changelog", "changelog"],
79
+ ];
80
+
81
+ const HEADER_TO_FIELD: Record<string, keyof ProjectProgress> = Object.fromEntries(
82
+ BODY_SECTIONS.map(([h, k]) => [h.toLowerCase(), k])
83
+ );
84
+
85
+ function buildBody(p: ProjectProgress): string {
86
+ return BODY_SECTIONS.filter(([, k]) => (p[k] as string | undefined)?.trim())
87
+ .map(([h, k]) => `## ${h}\n\n${(p[k] as string).trim()}`)
88
+ .join("\n\n");
61
89
  }
62
90
 
63
- function progressFile(slug: string): string {
64
- return resolve(progressDir(), `${slug}.json`);
91
+ function extractSections(body: string): Partial<ProjectProgress> {
92
+ const out: Partial<ProjectProgress> = {};
93
+ for (const part of body.split(/^## /m).slice(1)) {
94
+ const nl = part.indexOf("\n");
95
+ if (nl === -1) continue;
96
+ const header = part.slice(0, nl).trim().toLowerCase();
97
+ const content = part.slice(nl + 1).trim();
98
+ if (!content) continue;
99
+ const field = HEADER_TO_FIELD[header];
100
+ if (field) (out as Record<string, string>)[field as string] = content;
101
+ }
102
+ return out;
103
+ }
104
+
105
+ function isaFilePath(slug: string): string {
106
+ return resolve(paths.projectHistory(), slug, "ISA.md");
107
+ }
108
+
109
+ function ensureAndGetIsaFile(slug: string): string {
110
+ const dir = resolve(paths.projectHistory(), slug);
111
+ mkdirSync(dir, { recursive: true });
112
+ return resolve(dir, "ISA.md");
65
113
  }
66
114
 
67
115
  /**
68
116
  * Compute the default project slug from a cwd.
69
117
  *
70
118
  * Returns the FULL last path segment, lowercased, with non-[a-z0-9_-] chars
71
- * collapsed to a single hyphen. Critically: never split on `-` or any
72
- * separator within the basename. `/repos/portable-agent-layer`
73
- * `portable-agent-layer`, NOT `layer`.
119
+ * collapsed to a single hyphen. Never splits on `-`. `/repos/portable-agent-layer`
120
+ * `portable-agent-layer`, NOT `layer`.
74
121
  */
75
122
  export function defaultSlug(cwd: string): string {
76
123
  const base = basename(resolve(cwd));
@@ -83,8 +130,6 @@ export function defaultSlug(cwd: string): string {
83
130
 
84
131
  /**
85
132
  * Heuristic: does this directory look like a project root (has a project marker)?
86
- * Used by the SessionStart loader to hint the AI when an unregistered cwd is
87
- * project-shaped, so registration can be suggested in conversation.
88
133
  */
89
134
  export function looksLikeProjectRoot(cwd: string): boolean {
90
135
  const cwdAbs = resolve(cwd);
@@ -92,56 +137,62 @@ export function looksLikeProjectRoot(cwd: string): boolean {
92
137
  }
93
138
 
94
139
  export function readAllProjects(): ProjectProgress[] {
95
- const dir = progressDir();
96
- if (!existsSync(dir)) return [];
140
+ const base = paths.projectHistory();
141
+ if (!existsSync(base)) return [];
97
142
  const out: ProjectProgress[] = [];
98
- for (const file of readdirSync(dir).filter((f) => f.endsWith(".json"))) {
99
- try {
100
- const parsed = JSON.parse(
101
- readFileSync(resolve(dir, file), "utf-8")
102
- ) as ProjectProgress;
103
- if (parsed?.name && parsed?.path && parsed?.status) out.push(parsed);
104
- } catch {
105
- /* skip malformed */
106
- }
143
+ for (const slug of readdirSync(base)) {
144
+ const file = resolve(base, slug, "ISA.md");
145
+ if (!existsSync(file)) continue;
146
+ const p = readProject(slug);
147
+ if (p) out.push(p);
107
148
  }
108
149
  return out;
109
150
  }
110
151
 
111
152
  export function readProject(name: string): ProjectProgress | null {
112
- const file = progressFile(name);
153
+ const file = isaFilePath(name);
113
154
  if (!existsSync(file)) return null;
114
155
  try {
115
- return JSON.parse(readFileSync(file, "utf-8")) as ProjectProgress;
156
+ const content = readFileSync(file, "utf-8");
157
+ const { meta, body } = parse<IsaMeta>(content);
158
+ if (!meta?.name || !meta?.path || !meta?.status) return null;
159
+ return { ...meta, ...extractSections(body) };
116
160
  } catch {
117
161
  return null;
118
162
  }
119
163
  }
120
164
 
121
165
  export function writeProject(p: ProjectProgress): void {
122
- writeFileSync(progressFile(p.name), `${JSON.stringify(p, null, 2)}\n`, "utf-8");
166
+ const meta: Record<string, unknown> = {
167
+ name: p.name,
168
+ path: p.path,
169
+ status: p.status,
170
+ created: p.created,
171
+ updated: p.updated,
172
+ };
173
+ if (p.next?.length) meta.next = p.next;
174
+ if (p.blockers?.length) meta.blockers = p.blockers;
175
+ if (p.handoff) meta.handoff = p.handoff;
176
+ writeFileSync(ensureAndGetIsaFile(p.name), stringify(meta, buildBody(p)), "utf-8");
123
177
  }
124
178
 
125
179
  export function deleteProject(name: string): boolean {
126
- const file = progressFile(name);
180
+ const file = isaFilePath(name);
127
181
  if (!existsSync(file)) return false;
128
182
  unlinkSync(file);
183
+ try {
184
+ rmdirSync(resolve(paths.projectHistory(), name));
185
+ } catch {
186
+ /* dir not empty or already gone */
187
+ }
129
188
  return true;
130
189
  }
131
190
 
132
191
  /**
133
192
  * Resolve `cwd` to the registered project that contains it, if any.
134
193
  *
135
- * Rules:
136
- * - Exact-match registered paththat project.
137
- * - Descendant of exactly one registered path → that project.
138
- * - Descendant of multiple (nested) → longest registered path wins.
139
- * - Ancestor of registered project (parent-dir browse mode) → null.
140
- * - Unrelated cwd → null.
141
- *
142
- * The parent-dir case is the load-bearing one: opening `/repos/` (an ancestor
143
- * of multiple registered repos) MUST return null so the Stop-hook auto-write
144
- * doesn't ambiguously bump one of N children.
194
+ * Parent-dir browse mode (cwd is an ancestor of a registered project) → null.
195
+ * Multiple nested projectslongest registered path wins.
145
196
  */
146
197
  export function resolveProjectFromCwd(
147
198
  cwd: string,
@@ -171,7 +222,7 @@ export function isStale(
171
222
  * Walk up from `cwd` looking for the nearest dir with a project marker.
172
223
  * Returns absolute path or null. Bounded at 12 levels to avoid runaway walks.
173
224
  */
174
- export function findProjectRoot(cwd: string): string | null {
225
+ function findProjectRoot(cwd: string): string | null {
175
226
  let dir = resolve(cwd);
176
227
  const fsRoot = parsePath(dir).root;
177
228
  for (let i = 0; i < 12; i++) {
@@ -199,15 +250,9 @@ const MAX_INLINE_BULLETS = 3;
199
250
  /**
200
251
  * Format the SessionStart "Active Projects" section.
201
252
  *
202
- * Output is empty when there's nothing to say (no active/paused projects AND
203
- * no project-shaped unregistered cwd). When non-empty, includes:
204
- * - A `## Active Projects` block listing every active/paused project
205
- * - Full Objectives / Next / Blockers detail ONLY for the cwd-resolved project
206
- * (`→ here`); every other project renders as a one-liner with next/blocker
207
- * counts, to keep the section bounded as the project corpus grows
208
- * - A `⚠ stale` flag for projects updated > threshold days ago
209
- * - A trailing AI-visible hint when cwd resolves to no project but
210
- * `findProjectRoot(cwd)` reveals a project-shaped ancestor
253
+ * For the cwd-resolved project (`→ here`): full detail with Context, Objectives
254
+ * (from goal section), Next, and Blockers. For all others: compact one-liner with
255
+ * next/blocker counts only. Stale flag (>14d) and browse-mode hint included.
211
256
  */
212
257
  export function loadActiveProjectsContext(cwd: string = process.cwd()): string {
213
258
  const all = readAllProjects();
@@ -223,8 +268,7 @@ export function loadActiveProjectsContext(cwd: string = process.cwd()): string {
223
268
  const lines: string[] = [];
224
269
 
225
270
  if (visible.length > 0) {
226
- lines.push("## Active Projects");
227
- lines.push("");
271
+ lines.push("## Active Projects", "");
228
272
  const sorted = [...visible].sort((a, b) => b.updated.localeCompare(a.updated));
229
273
  for (const p of sorted) {
230
274
  const ago = formatAgo(p.updated);
@@ -235,23 +279,47 @@ export function loadActiveProjectsContext(cwd: string = process.cwd()): string {
235
279
 
236
280
  if (isResolved) {
237
281
  lines.push(`- **${p.name}** (${statusPrefix}${ago})${stale}${here}`);
238
- if (p.facts?.length) {
239
- lines.push(` Facts: ${p.facts.slice(0, MAX_INLINE_BULLETS).join("; ")}`);
282
+ if (p.context) {
283
+ const bullets = p.context
284
+ .split("\n")
285
+ .map((l) => l.replace(/^[-*]\s*/, "").trim())
286
+ .filter(Boolean);
287
+ lines.push(` Facts: ${bullets.slice(0, MAX_INLINE_BULLETS).join("; ")}`);
288
+ }
289
+ if (p.goal) {
290
+ const bullets = p.goal
291
+ .split("\n")
292
+ .map((l) => l.replace(/^[-*]\s*/, "").trim())
293
+ .filter(Boolean);
294
+ lines.push(` Objectives: ${bullets.slice(0, MAX_INLINE_BULLETS).join("; ")}`);
240
295
  }
241
- if (p.objectives?.length) {
242
- lines.push(
243
- ` Objectives: ${p.objectives.slice(0, MAX_INLINE_BULLETS).join("; ")}`
244
- );
296
+ if (p.constraints) {
297
+ const bullets = p.constraints
298
+ .split("\n")
299
+ .map((l) => l.replace(/^[-*]\s*/, "").trim())
300
+ .filter(Boolean);
301
+ lines.push(` Constraints: ${bullets.slice(0, MAX_INLINE_BULLETS).join("; ")}`);
245
302
  }
246
- if (p.next_steps?.length) {
247
- lines.push(` Next: ${p.next_steps.slice(0, MAX_INLINE_BULLETS).join("; ")}`);
303
+ if (p.next?.length) {
304
+ lines.push(` Next: ${p.next.slice(0, MAX_INLINE_BULLETS).join("; ")}`);
248
305
  }
249
306
  if (p.blockers?.length) {
250
307
  lines.push(` Blockers: ${p.blockers.slice(0, MAX_INLINE_BULLETS).join("; ")}`);
251
308
  }
309
+ if (p.criteria) {
310
+ const openIscs = p.criteria
311
+ .split("\n")
312
+ .filter((l) => /^-\s+\[ \]\s+ISC-\d+:/i.test(l))
313
+ .map((l) => l.replace(/^-\s+\[ \]\s+/, "").trim());
314
+ if (openIscs.length > 0) {
315
+ lines.push(
316
+ ` Open ISCs (${openIscs.length}): ${openIscs.slice(0, MAX_INLINE_BULLETS).join("; ")}`
317
+ );
318
+ }
319
+ }
252
320
  } else {
253
321
  const counts: string[] = [];
254
- if (p.next_steps?.length) counts.push(`${p.next_steps.length} next`);
322
+ if (p.next?.length) counts.push(`${p.next.length} next`);
255
323
  if (p.blockers?.length) counts.push(`${p.blockers.length} blockers`);
256
324
  const countsSuffix = counts.length > 0 ? ` — ${counts.join(", ")}` : "";
257
325
  lines.push(`- **${p.name}** (${statusPrefix}${ago})${countsSuffix}${stale}`);
@@ -9,7 +9,7 @@ import { existsSync, readdirSync, readFileSync } from "node:fs";
9
9
  import { resolve } from "node:path";
10
10
  import { palPkg } from "./paths";
11
11
 
12
- export interface SyncResult {
12
+ interface SyncResult {
13
13
  ok: boolean;
14
14
  issues: string[];
15
15
  }
@@ -5,7 +5,7 @@
5
5
  * Notes live at memory/relationship/YYYY-MM/YYYY-MM-DD.md
6
6
  * W = world (facts about user's situation)
7
7
  * O = opinion (preference with confidence)
8
- * B = biographical (what the AI did this session, first-person)
8
+ * Session = what the AI did this session (first-person)
9
9
  *
10
10
  * Extraction is handled by the relationship handler via Haiku inference.
11
11
  * This lib provides storage and reading utilities only.
@@ -15,9 +15,9 @@ import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
15
15
  import { resolve } from "node:path";
16
16
  import { ensureDir, paths } from "./paths";
17
17
 
18
- export type NoteType = "W" | "O" | "B";
18
+ type NoteType = "W" | "O" | "Session";
19
19
 
20
- export interface RelationshipNote {
20
+ interface RelationshipNote {
21
21
  type: NoteType;
22
22
  text: string;
23
23
  confidence?: number;
@@ -31,18 +31,6 @@ function dailyFilePath(date: Date): string {
31
31
  return resolve(monthDir, `${yyyy}-${mm}-${dd}.md`);
32
32
  }
33
33
 
34
- /** Check if a session already has notes in today's file */
35
- export function hasSessionNotes(sessionId: string): boolean {
36
- const filepath = dailyFilePath(new Date());
37
- if (!existsSync(filepath)) return false;
38
- try {
39
- const content = readFileSync(filepath, "utf-8");
40
- return content.includes(`<!-- session:${sessionId} -->`);
41
- } catch {
42
- return false;
43
- }
44
- }
45
-
46
34
  /** Deduplicate notes against what's already in today's file */
47
35
  function dedup(notes: RelationshipNote[], filepath: string): RelationshipNote[] {
48
36
  if (!existsSync(filepath)) return notes;
@@ -77,7 +65,7 @@ export function appendNotes(notes: RelationshipNote[], sessionId?: string): void
77
65
 
78
66
  const timestamp = new Date().toTimeString().slice(0, 5);
79
67
  lines.push(`## ${timestamp}`);
80
- if (sessionId) lines.push(`<!-- session:${sessionId} -->`);
68
+ if (sessionId) lines.push(`<!-- session:${sessionId} cwd:${process.cwd()} -->`);
81
69
 
82
70
  for (const note of fresh) {
83
71
  if (note.type === "O" && note.confidence !== undefined) {
@@ -17,7 +17,7 @@ import { palPkg, paths } from "./paths";
17
17
  import { similarity, tokenize } from "./text-similarity";
18
18
  import { readFramesForRetrieval } from "./wisdom";
19
19
 
20
- export const INDEX_VERSION = 1;
20
+ const INDEX_VERSION = 1;
21
21
 
22
22
  export interface IndexedDoc {
23
23
  id: string;
@@ -29,6 +29,7 @@ export interface IndexedDoc {
29
29
  len: number;
30
30
  displayPrinciple: string;
31
31
  displayContext: string;
32
+ cwd?: string;
32
33
  }
33
34
 
34
35
  export interface RetrievalIndex {
@@ -116,6 +117,7 @@ export function buildIndex(): RetrievalIndex {
116
117
  len,
117
118
  displayPrinciple: f.principle,
118
119
  displayContext: f.context,
120
+ cwd: f.cwd || undefined,
119
121
  });
120
122
  }
121
123
 
@@ -151,7 +153,7 @@ export function buildIndex(): RetrievalIndex {
151
153
  };
152
154
  }
153
155
 
154
- export function writeIndex(index: RetrievalIndex): void {
156
+ function writeIndex(index: RetrievalIndex): void {
155
157
  writeFileSync(paths.retrievalIndex(), JSON.stringify(index));
156
158
  }
157
159
 
@@ -182,7 +184,7 @@ export function isStale(index: RetrievalIndex): boolean {
182
184
  }
183
185
 
184
186
  /** Detached background rebuild — fire and forget, never throws. */
185
- export function spawnBackgroundRebuild(): void {
187
+ function spawnBackgroundRebuild(): void {
186
188
  try {
187
189
  const script = resolve(palPkg(), "src", "hooks", "lib", "retrieval-index.ts");
188
190
  const child = spawn("bun", ["run", script, "--rebuild"], {
@@ -18,7 +18,7 @@ const MAX_MATCHES = 2;
18
18
  const MAX_REMINDER_BYTES = 500;
19
19
  const PRINCIPLE_TRUNC = 200;
20
20
 
21
- export interface ScoredDoc {
21
+ interface ScoredDoc {
22
22
  doc: IndexedDoc;
23
23
  score: number;
24
24
  confidence: number;
@@ -38,7 +38,7 @@ function ageDecay(ts: string): number {
38
38
  }
39
39
 
40
40
  /** Raw IDF-weighted overlap score, capped tf, sqrt-length-normalized. */
41
- export function scoreDoc(
41
+ function scoreDoc(
42
42
  queryTerms: Set<string>,
43
43
  doc: IndexedDoc,
44
44
  df: Record<string, number>,
@@ -56,7 +56,7 @@ export function scoreDoc(
56
56
  }
57
57
 
58
58
  /** Upper bound: query treated as a doc with tf=1 per unique term. */
59
- export function selfScore(
59
+ function selfScore(
60
60
  queryTerms: Set<string>,
61
61
  df: Record<string, number>,
62
62
  N: number
@@ -75,7 +75,7 @@ function scopeMatches(doc: IndexedDoc, scopeKey: string): boolean {
75
75
 
76
76
  /** Rank the index against `query`. Returns docs above the confidence threshold,
77
77
  * scope-boosted and age-decayed, sorted high-to-low, capped at MAX_MATCHES. */
78
- export function rank(query: string, index: RetrievalIndex, cwd: string): ScoredDoc[] {
78
+ function rank(query: string, index: RetrievalIndex, cwd: string): ScoredDoc[] {
79
79
  const queryTerms = extractKeywords(query);
80
80
  if (queryTerms.size === 0) return [];
81
81
 
@@ -92,7 +92,10 @@ export function rank(query: string, index: RetrievalIndex, cwd: string): ScoredD
92
92
  for (const doc of index.docs) {
93
93
  const raw = scoreDoc(queryTerms, doc, index.df, N);
94
94
  if (raw === 0) continue;
95
- const scopeMatch = [...scopeTokens].some((t) => scopeMatches(doc, t));
95
+ // Exact cwd match when available; fingerprint heuristic for older captures.
96
+ const scopeMatch = doc.cwd
97
+ ? doc.cwd === cwd
98
+ : [...scopeTokens].some((t) => scopeMatches(doc, t));
96
99
  const boosted = raw * (scopeMatch ? SCOPE_BOOST : 1) * ageDecay(doc.ts);
97
100
  const confidence = boosted / self;
98
101
  if (confidence < CONFIDENCE_THRESHOLD) continue;
@@ -120,12 +123,8 @@ function formatAgo(ts: string): string {
120
123
  }
121
124
 
122
125
  function formatLine(s: ScoredDoc): string {
123
- const tag =
124
- s.doc.source === "wisdom"
125
- ? `[${s.doc.displayContext}]`
126
- : s.scopeMatch
127
- ? "[project]"
128
- : "[global]";
126
+ const scopeTag = s.scopeMatch ? "[project]" : "[global]";
127
+ const tag = s.doc.source === "wisdom" ? `[${s.doc.displayContext}]` : scopeTag;
129
128
  const text = s.doc.displayPrinciple || s.doc.displayContext || "";
130
129
  const principle = truncate(text, PRINCIPLE_TRUNC);
131
130
  if (s.doc.source === "wisdom") {
@@ -137,7 +136,7 @@ function formatLine(s: ScoredDoc): string {
137
136
  }
138
137
 
139
138
  /** Build the `<system-reminder>` block. Drops lowest-ranked lines until ≤500 bytes. */
140
- export function formatReminder(matches: ScoredDoc[]): string {
139
+ function formatReminder(matches: ScoredDoc[]): string {
141
140
  if (matches.length === 0) return "";
142
141
  let lines = matches.map(formatLine);
143
142
 
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  /** Dangerous command patterns — always blocked */
7
- export const BLOCKED_COMMANDS: [RegExp, string][] = [
7
+ const BLOCKED_COMMANDS: [RegExp, string][] = [
8
8
  [/rm\s+-rf\s+[/~]/, "Recursive delete of root or home"],
9
9
  [/mkfs\./, "Filesystem format"],
10
10
  [/dd\s+if=.*of=\/dev\//, "Raw disk write"],
@@ -16,7 +16,7 @@ export const BLOCKED_COMMANDS: [RegExp, string][] = [
16
16
  ];
17
17
 
18
18
  /** Hook-managed files — single source of truth */
19
- export const HOOK_MANAGED_FILES = [
19
+ const HOOK_MANAGED_FILES = [
20
20
  "CLAUDE.md",
21
21
  "AGENTS.md",
22
22
  "ratings.jsonl",
@@ -40,7 +40,7 @@ export const HOOK_MANAGED_FILES = [
40
40
  ];
41
41
 
42
42
  /** Hook-managed directories — AI must not write to or delete from these */
43
- export const HOOK_MANAGED_DIRS = [
43
+ const HOOK_MANAGED_DIRS = [
44
44
  "memory/signals",
45
45
  "memory/learning/failures",
46
46
  "memory/learning/session",
@@ -56,33 +56,33 @@ function escapeRegExp(s: string): string {
56
56
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
57
57
  }
58
58
 
59
+ /** PAL-deployed dirs — engine-managed, overwritten on every `pal install` */
60
+ const PAL_INSTALLED_DIRS_RE = /[/\\]\.pal[/\\](?:docs|skills|tools)[/\\]/;
61
+
59
62
  /** Paths that should never be written to */
60
- export const PROTECTED_PATHS: RegExp[] = [
63
+ const PROTECTED_PATHS: RegExp[] = [
61
64
  /^\/etc\//,
62
65
  /^\/boot\//,
63
66
  /^\/System\//,
64
67
  /\.ssh\/(?!config)/,
65
68
  /\.gnupg\//,
69
+ // Claude Code auto-memory — PAL owns memory; writes here indicate wrong system is being used
70
+ /\.claude\/projects\/[^/]+\/memory\//,
71
+ PAL_INSTALLED_DIRS_RE,
66
72
  // Derived from HOOK_MANAGED_FILES — scoped to managed roots only
67
73
  ...HOOK_MANAGED_FILES.map(
68
74
  (name) =>
69
- new RegExp(`[/\\\\]\\.(?:pal|claude|agents|cursor)[/\\\\].*${escapeRegExp(name)}$`)
75
+ new RegExp(
76
+ String.raw`[/\\]\.(?:pal|claude|agents|cursor)[/\\].*${escapeRegExp(name)}$`
77
+ )
70
78
  ),
71
79
  ];
72
80
 
73
- /** Patterns that warrant a warning (logged but not blocked) */
74
- export const WARN_COMMANDS: RegExp[] = [
75
- /git\s+push\s+.*--force/,
76
- /git\s+reset\s+--hard/,
77
- /drop\s+(?:table|database)/i,
78
- /truncate\s+table/i,
79
- ];
80
-
81
81
  /** Roots where managed files/dirs are protected (user state, not repo templates) */
82
82
  const MANAGED_ROOTS = [".pal/", ".claude/", ".agents/", ".config/opencode/", ".cursor/"];
83
83
 
84
84
  function isUnderManagedRoot(path: string): boolean {
85
- const normalized = path.replace(/\\/g, "/");
85
+ const normalized = path.replaceAll("\\", "/");
86
86
  return MANAGED_ROOTS.some(
87
87
  (root) => normalized.includes(`/${root}`) || normalized.includes(`\\.${root}`)
88
88
  );
@@ -102,7 +102,7 @@ export function checkBashCommand(cmd: string): string | null {
102
102
  const segments = cmd.split(/[|;&&]/).map((s) => s.trim());
103
103
  for (const name of HOOK_MANAGED_FILES) {
104
104
  const pattern = new RegExp(
105
- `\\.(?:pal|claude|agents|cursor|config/opencode)[/\\\\]\\S*${escapeRegExp(name)}`
105
+ String.raw`\.(?:pal|claude|agents|cursor|config/opencode)[/\\]\S*${escapeRegExp(name)}`
106
106
  );
107
107
  const managed = segments.filter((s) => pattern.test(s));
108
108
  if (managed.length > 0 && !managed.every((s) => READ_ONLY_COMMANDS.test(s))) {
@@ -111,7 +111,7 @@ export function checkBashCommand(cmd: string): string | null {
111
111
  }
112
112
  for (const dir of HOOK_MANAGED_DIRS) {
113
113
  const pattern = new RegExp(
114
- `\\.(?:pal|claude|agents|cursor|config/opencode)[/\\\\]\\S*${escapeRegExp(dir)}`
114
+ String.raw`\.(?:pal|claude|agents|cursor|config/opencode)[/\\]\S*${escapeRegExp(dir)}`
115
115
  );
116
116
  const managed = segments.filter((s) => pattern.test(s));
117
117
  if (managed.length > 0 && !managed.every((s) => READ_ONLY_COMMANDS.test(s))) {
@@ -123,7 +123,7 @@ export function checkBashCommand(cmd: string): string | null {
123
123
 
124
124
  /** Check a file path against protected patterns. Returns a reason string or null. */
125
125
  export function checkFilePath(filePath: string): string | null {
126
- const normalized = filePath.replace(/\\/g, "/");
126
+ const normalized = filePath.replaceAll("\\", "/");
127
127
  // Check hook-managed files — only under managed roots (not repo templates)
128
128
  if (isUnderManagedRoot(normalized)) {
129
129
  const matchedFile = HOOK_MANAGED_FILES.find((name) =>
@@ -138,7 +138,13 @@ export function checkFilePath(filePath: string): string | null {
138
138
  if (matchedDir) {
139
139
  return `${matchedDir}/ is managed automatically by hooks — do not edit directly`;
140
140
  }
141
- // Check system-protected paths
141
+ // PAL-deployed dirs — edit source in the PAL repo, not the installed copy
142
+ if (PAL_INSTALLED_DIRS_RE.test(normalized)) {
143
+ const match = new RegExp(/\.pal[/\\](docs|skills|tools)/).exec(normalized);
144
+ const dir = match ? match[1] : "docs/skills/tools";
145
+ return `~/.pal/${dir}/ is managed by 'pal install' — edit the source in the PAL repo instead`;
146
+ }
147
+ // Check remaining system-protected paths
142
148
  if (PROTECTED_PATHS.some((pattern) => pattern.test(filePath))) {
143
149
  return `Protected path: ${filePath}`;
144
150
  }