portable-agent-layer 0.36.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.
- package/README.md +1 -0
- package/assets/skills/analyze-pdf/tools/pdf-download.ts +1 -1
- package/assets/skills/analyze-youtube/tools/youtube-analyze.ts +1 -1
- package/assets/skills/consulting-report/tools/dev.ts +2 -2
- package/assets/skills/consulting-report/tools/generate-pdf.ts +9 -9
- package/assets/skills/consulting-report/tools/scaffold.ts +2 -2
- package/assets/skills/create-pdf/tools/md-to-html-pdf.ts +2 -2
- package/assets/skills/opinion/tools/opinion.ts +3 -2
- package/assets/skills/presentation/SKILL.md +1 -1
- package/assets/skills/presentation/tools/doctor.ts +2 -5
- package/assets/skills/presentation/tools/lib/inline.ts +6 -11
- package/assets/skills/presentation/tools/lib/lint-helpers.ts +2 -2
- package/assets/skills/presentation/tools/lib/lint-rules.ts +5 -2
- package/assets/skills/presentation/tools/setup-template.ts +10 -7
- package/assets/skills/projects/SKILL.md +44 -20
- package/assets/skills/research/tools/gemini-search.ts +2 -2
- package/assets/skills/research/tools/grok-search.ts +2 -2
- package/assets/skills/research/tools/perplexity-search.ts +2 -2
- package/assets/skills/telos/tools/update-telos.ts +0 -1
- package/assets/templates/PAL/ALGORITHM.md +27 -3
- package/assets/templates/hooks.codex.json +44 -0
- package/assets/templates/hooks.cursor.json +11 -5
- package/package.json +2 -1
- package/src/cli/index.ts +112 -14
- package/src/cli/migrate.ts +299 -0
- package/src/cli/setup-identity.ts +3 -3
- package/src/cli/setup-telos.ts +0 -1
- package/src/hooks/CompactRecover.ts +11 -5
- package/src/hooks/LoadContext.ts +14 -2
- package/src/hooks/PreCompactPersist.ts +26 -34
- package/src/hooks/SecurityValidator.ts +43 -21
- package/src/hooks/StopOrchestrator.ts +4 -1
- package/src/hooks/UserPromptOrchestrator.ts +4 -2
- package/src/hooks/handlers/auto-graduate.ts +2 -2
- package/src/hooks/handlers/backup.ts +3 -3
- package/src/hooks/handlers/failure.ts +5 -3
- package/src/hooks/handlers/inject-retrieval.ts +29 -6
- package/src/hooks/handlers/persist-last-exchange.ts +76 -0
- package/src/hooks/handlers/rating.ts +2 -1
- package/src/hooks/handlers/readme-sync.ts +3 -2
- package/src/hooks/handlers/session-intelligence.ts +9 -8
- package/src/hooks/handlers/session-name.ts +2 -2
- package/src/hooks/handlers/synthesis.ts +5 -2
- package/src/hooks/handlers/update-counts.ts +3 -2
- package/src/hooks/lib/agent.ts +20 -18
- package/src/hooks/lib/context.ts +45 -117
- package/src/hooks/lib/entities.ts +7 -7
- package/src/hooks/lib/frontmatter.ts +4 -4
- package/src/hooks/lib/graduation.ts +7 -6
- package/src/hooks/lib/inference.ts +6 -2
- package/src/hooks/lib/learning-category.ts +1 -1
- package/src/hooks/lib/learning-store.ts +6 -1
- package/src/hooks/lib/notify.ts +2 -2
- package/src/hooks/lib/opinions.ts +3 -3
- package/src/hooks/lib/paths.ts +2 -0
- package/src/hooks/lib/projects.ts +142 -74
- package/src/hooks/lib/readme-sync.ts +1 -1
- package/src/hooks/lib/relationship.ts +3 -15
- package/src/hooks/lib/retrieval-index.ts +5 -3
- package/src/hooks/lib/retrieval.ts +11 -12
- package/src/hooks/lib/security.ts +22 -18
- package/src/hooks/lib/semi-static.ts +4 -2
- package/src/hooks/lib/session-names.ts +1 -1
- package/src/hooks/lib/settings.ts +1 -1
- package/src/hooks/lib/setup.ts +2 -60
- package/src/hooks/lib/signals.ts +2 -2
- package/src/hooks/lib/stdin.ts +1 -1
- package/src/hooks/lib/stop.ts +13 -6
- package/src/hooks/lib/token-usage.ts +1 -2
- package/src/hooks/lib/transcript.ts +1 -1
- package/src/hooks/lib/wisdom.ts +5 -5
- package/src/hooks/lib/work-tracking.ts +8 -14
- package/src/targets/codex/install.ts +95 -0
- package/src/targets/codex/uninstall.ts +70 -0
- package/src/targets/lib.ts +140 -14
- package/src/targets/opencode/plugin.ts +22 -11
- package/src/tools/agent/algorithm-reflect.ts +1 -1
- package/src/tools/agent/analyze.ts +18 -18
- package/src/tools/agent/handoff-note.ts +1 -1
- package/src/tools/agent/project.ts +375 -75
- package/src/tools/agent/synthesize.ts +6 -42
- package/src/tools/agent/thread.ts +15 -14
- package/src/tools/agent/wisdom-frame.ts +9 -3
- package/src/tools/import.ts +1 -1
- package/src/tools/relationship-reflect.ts +13 -11
- package/src/tools/self-model.ts +20 -16
- package/src/tools/session-summary.ts +3 -3
- package/src/tools/token-cost.ts +15 -16
- package/assets/skills/telos/tools/update-projects.ts +0 -106
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Projects — registry of user-curated projects
|
|
2
|
+
* Projects — registry of user-curated projects backed by ISA.md files.
|
|
3
3
|
*
|
|
4
|
-
* Each project lives in `~/.pal/memory/
|
|
5
|
-
*
|
|
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
|
-
|
|
38
|
-
objectives?: string[];
|
|
39
|
-
next_steps?: string[];
|
|
31
|
+
next?: string[];
|
|
40
32
|
blockers?: string[];
|
|
41
33
|
handoff?: string;
|
|
42
|
-
|
|
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
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
64
|
-
|
|
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.
|
|
72
|
-
*
|
|
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
|
|
96
|
-
if (!existsSync(
|
|
140
|
+
const base = paths.projectHistory();
|
|
141
|
+
if (!existsSync(base)) return [];
|
|
97
142
|
const out: ProjectProgress[] = [];
|
|
98
|
-
for (const
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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 =
|
|
153
|
+
const file = isaFilePath(name);
|
|
113
154
|
if (!existsSync(file)) return null;
|
|
114
155
|
try {
|
|
115
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
*
|
|
136
|
-
*
|
|
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 projects → longest 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
|
-
|
|
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
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
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.
|
|
239
|
-
|
|
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.
|
|
242
|
-
|
|
243
|
-
|
|
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.
|
|
247
|
-
lines.push(` Next: ${p.
|
|
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.
|
|
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}`);
|
|
@@ -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
|
-
|
|
18
|
+
type NoteType = "W" | "O" | "Session";
|
|
19
19
|
|
|
20
|
-
|
|
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
|
-
/** @deprecated No longer called — relationship notes are written in ALGORITHM LEARN phase */
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
124
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
43
|
+
const HOOK_MANAGED_DIRS = [
|
|
44
44
|
"memory/signals",
|
|
45
45
|
"memory/learning/failures",
|
|
46
46
|
"memory/learning/session",
|
|
@@ -56,8 +56,11 @@ 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
|
-
|
|
63
|
+
const PROTECTED_PATHS: RegExp[] = [
|
|
61
64
|
/^\/etc\//,
|
|
62
65
|
/^\/boot\//,
|
|
63
66
|
/^\/System\//,
|
|
@@ -65,26 +68,21 @@ export const PROTECTED_PATHS: RegExp[] = [
|
|
|
65
68
|
/\.gnupg\//,
|
|
66
69
|
// Claude Code auto-memory — PAL owns memory; writes here indicate wrong system is being used
|
|
67
70
|
/\.claude\/projects\/[^/]+\/memory\//,
|
|
71
|
+
PAL_INSTALLED_DIRS_RE,
|
|
68
72
|
// Derived from HOOK_MANAGED_FILES — scoped to managed roots only
|
|
69
73
|
...HOOK_MANAGED_FILES.map(
|
|
70
74
|
(name) =>
|
|
71
|
-
new RegExp(
|
|
75
|
+
new RegExp(
|
|
76
|
+
String.raw`[/\\]\.(?:pal|claude|agents|cursor)[/\\].*${escapeRegExp(name)}$`
|
|
77
|
+
)
|
|
72
78
|
),
|
|
73
79
|
];
|
|
74
80
|
|
|
75
|
-
/** Patterns that warrant a warning (logged but not blocked) */
|
|
76
|
-
export const WARN_COMMANDS: RegExp[] = [
|
|
77
|
-
/git\s+push\s+.*--force/,
|
|
78
|
-
/git\s+reset\s+--hard/,
|
|
79
|
-
/drop\s+(?:table|database)/i,
|
|
80
|
-
/truncate\s+table/i,
|
|
81
|
-
];
|
|
82
|
-
|
|
83
81
|
/** Roots where managed files/dirs are protected (user state, not repo templates) */
|
|
84
82
|
const MANAGED_ROOTS = [".pal/", ".claude/", ".agents/", ".config/opencode/", ".cursor/"];
|
|
85
83
|
|
|
86
84
|
function isUnderManagedRoot(path: string): boolean {
|
|
87
|
-
const normalized = path.
|
|
85
|
+
const normalized = path.replaceAll("\\", "/");
|
|
88
86
|
return MANAGED_ROOTS.some(
|
|
89
87
|
(root) => normalized.includes(`/${root}`) || normalized.includes(`\\.${root}`)
|
|
90
88
|
);
|
|
@@ -104,7 +102,7 @@ export function checkBashCommand(cmd: string): string | null {
|
|
|
104
102
|
const segments = cmd.split(/[|;&&]/).map((s) => s.trim());
|
|
105
103
|
for (const name of HOOK_MANAGED_FILES) {
|
|
106
104
|
const pattern = new RegExp(
|
|
107
|
-
|
|
105
|
+
String.raw`\.(?:pal|claude|agents|cursor|config/opencode)[/\\]\S*${escapeRegExp(name)}`
|
|
108
106
|
);
|
|
109
107
|
const managed = segments.filter((s) => pattern.test(s));
|
|
110
108
|
if (managed.length > 0 && !managed.every((s) => READ_ONLY_COMMANDS.test(s))) {
|
|
@@ -113,7 +111,7 @@ export function checkBashCommand(cmd: string): string | null {
|
|
|
113
111
|
}
|
|
114
112
|
for (const dir of HOOK_MANAGED_DIRS) {
|
|
115
113
|
const pattern = new RegExp(
|
|
116
|
-
|
|
114
|
+
String.raw`\.(?:pal|claude|agents|cursor|config/opencode)[/\\]\S*${escapeRegExp(dir)}`
|
|
117
115
|
);
|
|
118
116
|
const managed = segments.filter((s) => pattern.test(s));
|
|
119
117
|
if (managed.length > 0 && !managed.every((s) => READ_ONLY_COMMANDS.test(s))) {
|
|
@@ -125,7 +123,7 @@ export function checkBashCommand(cmd: string): string | null {
|
|
|
125
123
|
|
|
126
124
|
/** Check a file path against protected patterns. Returns a reason string or null. */
|
|
127
125
|
export function checkFilePath(filePath: string): string | null {
|
|
128
|
-
const normalized = filePath.
|
|
126
|
+
const normalized = filePath.replaceAll("\\", "/");
|
|
129
127
|
// Check hook-managed files — only under managed roots (not repo templates)
|
|
130
128
|
if (isUnderManagedRoot(normalized)) {
|
|
131
129
|
const matchedFile = HOOK_MANAGED_FILES.find((name) =>
|
|
@@ -140,7 +138,13 @@ export function checkFilePath(filePath: string): string | null {
|
|
|
140
138
|
if (matchedDir) {
|
|
141
139
|
return `${matchedDir}/ is managed automatically by hooks — do not edit directly`;
|
|
142
140
|
}
|
|
143
|
-
//
|
|
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
|
|
144
148
|
if (PROTECTED_PATHS.some((pattern) => pattern.test(filePath))) {
|
|
145
149
|
return `Protected path: ${filePath}`;
|
|
146
150
|
}
|
|
@@ -15,7 +15,7 @@ import { palHome, paths } from "./paths";
|
|
|
15
15
|
import { readFramePrinciples } from "./wisdom";
|
|
16
16
|
|
|
17
17
|
/** A single semi-static context source — built at session stop, loaded natively at session start. */
|
|
18
|
-
|
|
18
|
+
interface SemiStaticSource {
|
|
19
19
|
/** Absolute path used in @imports (CLAUDE.md), instructions[] (opencode), and digest writes. */
|
|
20
20
|
readonly path: string;
|
|
21
21
|
/** When true, session-stop handler writes build result to path. */
|
|
@@ -65,7 +65,9 @@ export function loadSynthesisRecommendations(): string {
|
|
|
65
65
|
|
|
66
66
|
const content = readFileSync(resolve(monthDir, files[0]), "utf-8");
|
|
67
67
|
|
|
68
|
-
const recMatch =
|
|
68
|
+
const recMatch = new RegExp(
|
|
69
|
+
/## Recommendations\n\n([\s\S]*?)(?:\n##|\n$|$)/
|
|
70
|
+
).exec(content);
|
|
69
71
|
if (!recMatch?.[1]?.trim()) continue;
|
|
70
72
|
|
|
71
73
|
const recs = recMatch[1]
|