portable-agent-layer 0.41.1 → 0.43.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/.husky/install.mjs +8 -0
- package/README.md +2 -1
- package/assets/skills/analyze-youtube/SKILL.md +1 -1
- package/assets/skills/consulting-report/template/components/code-block.tsx +21 -0
- package/assets/skills/consulting-report/template/components/cover-page.tsx +88 -27
- package/assets/skills/consulting-report/template/components/decision-table.tsx +62 -0
- package/assets/skills/consulting-report/template/components/process-stage.tsx +28 -0
- package/assets/skills/consulting-report/template/components/tier-matrix.tsx +102 -0
- package/assets/skills/consulting-report/template/lib/types.ts +49 -1
- package/assets/skills/consulting-report/tools/generate-pdf.ts +99 -16
- package/assets/skills/entities/SKILL.md +95 -0
- package/assets/skills/telos/SKILL.md +1 -1
- package/assets/templates/PAL/ALGORITHM.md +1 -1
- package/assets/templates/PAL/README.md +2 -2
- package/assets/templates/PAL/SYSTEM_ARCHITECTURE.md +1 -1
- package/assets/templates/rules.codex.rules +64 -0
- package/assets/templates/settings.claude.json +2 -1
- package/package.json +11 -12
- package/src/cli/index.ts +8 -0
- package/src/cli/knowledge.ts +620 -0
- package/src/cli/migrate.ts +188 -3
- package/src/hooks/lib/claude-md.ts +6 -2
- package/src/hooks/lib/export.ts +1 -1
- package/src/hooks/lib/paths.ts +3 -1
- package/src/targets/codex/install.ts +14 -0
- package/src/targets/codex/uninstall.ts +14 -0
- package/src/targets/lib.ts +53 -36
- package/src/tools/knowledge/graph.ts +395 -0
- package/src/tools/knowledge/ingest.ts +409 -0
- package/src/tools/knowledge/lib.ts +493 -0
- package/assets/skills/extract-entities/SKILL.md +0 -62
- package/assets/skills/extract-entities/tools/entity-save.ts +0 -110
- package/src/hooks/lib/entities.ts +0 -304
- package/src/tools/export.ts +0 -40
- package/src/tools/import.ts +0 -111
package/src/cli/migrate.ts
CHANGED
|
@@ -10,9 +10,9 @@
|
|
|
10
10
|
* pending work without running anything.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
13
|
+
import { existsSync, readdirSync, readFileSync, renameSync } from "node:fs";
|
|
14
14
|
import { resolve } from "node:path";
|
|
15
|
-
import { paths } from "../hooks/lib/paths";
|
|
15
|
+
import { palHome, paths } from "../hooks/lib/paths";
|
|
16
16
|
import {
|
|
17
17
|
legacyJsonToProgress,
|
|
18
18
|
type ProjectProgress,
|
|
@@ -21,6 +21,14 @@ import {
|
|
|
21
21
|
writeProject,
|
|
22
22
|
} from "../hooks/lib/projects";
|
|
23
23
|
import { readThreads, type Thread, writeThreads } from "../tools/agent/thread";
|
|
24
|
+
import { appendSourceLog } from "../tools/knowledge/ingest";
|
|
25
|
+
import {
|
|
26
|
+
type Entity,
|
|
27
|
+
type EntityFrontmatter,
|
|
28
|
+
exists as knowledgeExists,
|
|
29
|
+
save as knowledgeSave,
|
|
30
|
+
slugify,
|
|
31
|
+
} from "../tools/knowledge/lib";
|
|
24
32
|
|
|
25
33
|
// ── Types ─────────────────────────────────────────────────────────
|
|
26
34
|
|
|
@@ -175,9 +183,186 @@ const v2ThreadsToIsc: Migration = {
|
|
|
175
183
|
},
|
|
176
184
|
};
|
|
177
185
|
|
|
186
|
+
// ── v3-entities-to-knowledge: entity-index.json → knowledge/*.md ──
|
|
187
|
+
|
|
188
|
+
interface LegacyPerson {
|
|
189
|
+
id: string;
|
|
190
|
+
name: string;
|
|
191
|
+
first_seen: string;
|
|
192
|
+
occurrences: number;
|
|
193
|
+
source_ids: string[];
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
interface LegacyCompany {
|
|
197
|
+
id: string;
|
|
198
|
+
name: string;
|
|
199
|
+
domain: string | null;
|
|
200
|
+
first_seen: string;
|
|
201
|
+
occurrences: number;
|
|
202
|
+
source_ids: string[];
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
interface LegacyIndex {
|
|
206
|
+
version?: string;
|
|
207
|
+
people?: Record<string, LegacyPerson>;
|
|
208
|
+
companies?: Record<string, LegacyCompany>;
|
|
209
|
+
links?: Record<string, unknown>;
|
|
210
|
+
sources?: Record<string, unknown>;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function legacyEntitiesPath(): string {
|
|
214
|
+
// Read from PAL_HOME-aware location to match where the legacy store lived;
|
|
215
|
+
// computed locally now that paths.entities() is being retired alongside this migration.
|
|
216
|
+
const home = palHome();
|
|
217
|
+
const dir = resolve(home, "memory", "entities");
|
|
218
|
+
return resolve(dir, "entity-index.json");
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function readLegacyIndex(): LegacyIndex | null {
|
|
222
|
+
const p = legacyEntitiesPath();
|
|
223
|
+
if (!existsSync(p)) return null;
|
|
224
|
+
try {
|
|
225
|
+
return JSON.parse(readFileSync(p, "utf-8")) as LegacyIndex;
|
|
226
|
+
} catch {
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function countLegacyEntries(idx: LegacyIndex): number {
|
|
232
|
+
return Object.keys(idx.people ?? {}).length + Object.keys(idx.companies ?? {}).length;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function legacyPersonToEntity(legacy: LegacyPerson): Entity {
|
|
236
|
+
const fm: EntityFrontmatter = {
|
|
237
|
+
title: legacy.name,
|
|
238
|
+
type: "person",
|
|
239
|
+
tags: [],
|
|
240
|
+
created: legacy.first_seen,
|
|
241
|
+
updated: legacy.first_seen,
|
|
242
|
+
quality: 5,
|
|
243
|
+
status: "seedling",
|
|
244
|
+
related: [],
|
|
245
|
+
legacy_id: legacy.id,
|
|
246
|
+
occurrences: legacy.occurrences,
|
|
247
|
+
};
|
|
248
|
+
let body = "";
|
|
249
|
+
for (const sourceId of legacy.source_ids) {
|
|
250
|
+
body = appendSourceLog(body, sourceId, null, {}, legacy.first_seen);
|
|
251
|
+
}
|
|
252
|
+
return { domain: "People", slug: slugify(legacy.name), frontmatter: fm, body };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function legacyCompanyToEntity(legacy: LegacyCompany): Entity {
|
|
256
|
+
const baseKey = legacy.domain?.trim() ? legacy.domain : legacy.name;
|
|
257
|
+
const fm: EntityFrontmatter = {
|
|
258
|
+
title: legacy.name,
|
|
259
|
+
type: "company",
|
|
260
|
+
tags: [],
|
|
261
|
+
created: legacy.first_seen,
|
|
262
|
+
updated: legacy.first_seen,
|
|
263
|
+
quality: 5,
|
|
264
|
+
status: "seedling",
|
|
265
|
+
related: [],
|
|
266
|
+
legacy_id: legacy.id,
|
|
267
|
+
occurrences: legacy.occurrences,
|
|
268
|
+
};
|
|
269
|
+
if (legacy.domain) fm.domain_name = legacy.domain;
|
|
270
|
+
let body = "";
|
|
271
|
+
for (const sourceId of legacy.source_ids) {
|
|
272
|
+
body = appendSourceLog(body, sourceId, null, {}, legacy.first_seen);
|
|
273
|
+
}
|
|
274
|
+
return { domain: "Companies", slug: slugify(baseKey), frontmatter: fm, body };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const v3EntitiesToKnowledge: Migration = {
|
|
278
|
+
id: "v3-entities-to-knowledge",
|
|
279
|
+
description: "Migrate legacy entity-index.json to knowledge/{People,Companies}/*.md",
|
|
280
|
+
|
|
281
|
+
check() {
|
|
282
|
+
const idx = readLegacyIndex();
|
|
283
|
+
if (!idx) return { pending: false };
|
|
284
|
+
const total = countLegacyEntries(idx);
|
|
285
|
+
if (total === 0) return { pending: false };
|
|
286
|
+
// Skip if every entity already exists in the new store (idempotent).
|
|
287
|
+
let remaining = 0;
|
|
288
|
+
for (const p of Object.values(idx.people ?? {})) {
|
|
289
|
+
if (!knowledgeExists("People", slugify(p.name))) remaining++;
|
|
290
|
+
}
|
|
291
|
+
for (const c of Object.values(idx.companies ?? {})) {
|
|
292
|
+
const key = c.domain?.trim() ? c.domain : c.name;
|
|
293
|
+
if (!knowledgeExists("Companies", slugify(key))) remaining++;
|
|
294
|
+
}
|
|
295
|
+
return {
|
|
296
|
+
pending: remaining > 0,
|
|
297
|
+
detail: remaining > 0 ? `${remaining} of ${total} entries to migrate` : undefined,
|
|
298
|
+
};
|
|
299
|
+
},
|
|
300
|
+
|
|
301
|
+
run(dryRun = false): MigrationResult {
|
|
302
|
+
const idx = readLegacyIndex();
|
|
303
|
+
if (!idx) return { migrated: 0, skipped: 0, results: [] };
|
|
304
|
+
|
|
305
|
+
let migrated = 0;
|
|
306
|
+
let skipped = 0;
|
|
307
|
+
const results: string[] = [];
|
|
308
|
+
|
|
309
|
+
// Refuse to silently drop links/sources if a future legacy index has them.
|
|
310
|
+
const linksCount = Object.keys(idx.links ?? {}).length;
|
|
311
|
+
const sourcesCount = Object.keys(idx.sources ?? {}).length;
|
|
312
|
+
if (linksCount > 0 || sourcesCount > 0) {
|
|
313
|
+
results.push(
|
|
314
|
+
`aborted: legacy index has ${linksCount} link(s) and ${sourcesCount} source(s) — no destination in new store`
|
|
315
|
+
);
|
|
316
|
+
return { migrated: 0, skipped: linksCount + sourcesCount, results };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
for (const legacy of Object.values(idx.people ?? {})) {
|
|
320
|
+
const entity = legacyPersonToEntity(legacy);
|
|
321
|
+
if (knowledgeExists(entity.domain, entity.slug)) {
|
|
322
|
+
skipped++;
|
|
323
|
+
results.push(`People/${entity.slug}: skipped (already in new store)`);
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
if (!dryRun) knowledgeSave(entity);
|
|
327
|
+
migrated++;
|
|
328
|
+
results.push(`People/${entity.slug}: ${dryRun ? "would migrate" : "migrated"}`);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
for (const legacy of Object.values(idx.companies ?? {})) {
|
|
332
|
+
const entity = legacyCompanyToEntity(legacy);
|
|
333
|
+
if (knowledgeExists(entity.domain, entity.slug)) {
|
|
334
|
+
skipped++;
|
|
335
|
+
results.push(`Companies/${entity.slug}: skipped (already in new store)`);
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
if (!dryRun) knowledgeSave(entity);
|
|
339
|
+
migrated++;
|
|
340
|
+
results.push(`Companies/${entity.slug}: ${dryRun ? "would migrate" : "migrated"}`);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// After a successful, non-dry-run migration, archive the legacy file so
|
|
344
|
+
// re-runs don't repeatedly load and skip its contents.
|
|
345
|
+
if (!dryRun && migrated > 0) {
|
|
346
|
+
const src = legacyEntitiesPath();
|
|
347
|
+
if (existsSync(src)) {
|
|
348
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
349
|
+
const archived = `${src}.migrated-${date}`;
|
|
350
|
+
try {
|
|
351
|
+
renameSync(src, archived);
|
|
352
|
+
results.push(`archived legacy index → ${archived}`);
|
|
353
|
+
} catch (e) {
|
|
354
|
+
results.push(`warn: could not rename legacy index (${(e as Error).message})`);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return { migrated, skipped, results };
|
|
360
|
+
},
|
|
361
|
+
};
|
|
362
|
+
|
|
178
363
|
// ── Registry ──────────────────────────────────────────────────────
|
|
179
364
|
|
|
180
|
-
const MIGRATIONS: Migration[] = [v1Projects, v2ThreadsToIsc];
|
|
365
|
+
const MIGRATIONS: Migration[] = [v1Projects, v2ThreadsToIsc, v3EntitiesToKnowledge];
|
|
181
366
|
|
|
182
367
|
// ── Public API ────────────────────────────────────────────────────
|
|
183
368
|
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
lstatSync,
|
|
14
14
|
readdirSync,
|
|
15
15
|
readFileSync,
|
|
16
|
+
readlinkSync,
|
|
16
17
|
statSync,
|
|
17
18
|
symlinkSync,
|
|
18
19
|
unlinkSync,
|
|
@@ -54,8 +55,11 @@ function latestMtime(...filePaths: string[]): number {
|
|
|
54
55
|
function ensureOneSymlink(linkPath: string, targetPath: string): void {
|
|
55
56
|
try {
|
|
56
57
|
const stat = lstatSync(linkPath);
|
|
57
|
-
if (
|
|
58
|
-
|
|
58
|
+
if (stat.isSymbolicLink()) {
|
|
59
|
+
const currentTarget = resolve(dirname(linkPath), readlinkSync(linkPath));
|
|
60
|
+
if (currentTarget === targetPath) return;
|
|
61
|
+
}
|
|
62
|
+
unlinkSync(linkPath);
|
|
59
63
|
} catch {
|
|
60
64
|
// doesn't exist — create it
|
|
61
65
|
}
|
package/src/hooks/lib/export.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared export logic — zips user state directories.
|
|
3
|
-
* Used by
|
|
3
|
+
* Used by cli/index.ts (pal cli export) and handlers/backup.ts (automatic).
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { existsSync, readdirSync } from "node:fs";
|
package/src/hooks/lib/paths.ts
CHANGED
|
@@ -47,7 +47,8 @@ export const paths = {
|
|
|
47
47
|
wisdom: () => ensureDir(home("memory", "wisdom", "frames")),
|
|
48
48
|
wisdomState: () => ensureDir(home("memory", "wisdom", "state")),
|
|
49
49
|
relationship: () => ensureDir(home("memory", "relationship")),
|
|
50
|
-
|
|
50
|
+
knowledge: () => ensureDir(home("memory", "knowledge")),
|
|
51
|
+
knowledgeDomain: (d: string) => ensureDir(home("memory", "knowledge", d)),
|
|
51
52
|
failures: () => ensureDir(home("memory", "learning", "failures")),
|
|
52
53
|
retrievalIndex: () => home("memory", "learning", ".retrieval-index.json"),
|
|
53
54
|
progress: () => ensureDir(home("memory", "state", "progress")),
|
|
@@ -80,6 +81,7 @@ export const assets = {
|
|
|
80
81
|
cursorHooksTemplate: () => pkg("assets", "templates", "hooks.cursor.json"),
|
|
81
82
|
copilotHooksTemplate: () => pkg("assets", "templates", "hooks.copilot.json"),
|
|
82
83
|
codexHooksTemplate: () => pkg("assets", "templates", "hooks.codex.json"),
|
|
84
|
+
codexRulesTemplate: () => pkg("assets", "templates", "rules.codex.rules"),
|
|
83
85
|
agentTools: () => pkg("src", "tools", "agent"),
|
|
84
86
|
palDocs: () => pkg("assets", "templates", "PAL"),
|
|
85
87
|
} as const;
|
|
@@ -19,8 +19,10 @@ import {
|
|
|
19
19
|
countSkills,
|
|
20
20
|
generateSkillIndex,
|
|
21
21
|
loadCodexHooksTemplate,
|
|
22
|
+
loadCodexRulesTemplate,
|
|
22
23
|
log,
|
|
23
24
|
mergeCodexHooks,
|
|
25
|
+
mergeCodexRules,
|
|
24
26
|
readJson,
|
|
25
27
|
scaffoldPalSettings,
|
|
26
28
|
writeJson,
|
|
@@ -56,6 +58,7 @@ function enableCodexHooks(configPath: string): void {
|
|
|
56
58
|
const PKG_ROOT = palPkg().replaceAll("\\", "/");
|
|
57
59
|
const CODEX_DIR = platform.codexDir();
|
|
58
60
|
const HOOKS_FILE = resolve(CODEX_DIR, "hooks.json");
|
|
61
|
+
const RULES_FILE = resolve(CODEX_DIR, "rules", "default.rules");
|
|
59
62
|
|
|
60
63
|
// --- Ensure ~/.codex/ exists ---
|
|
61
64
|
mkdirSync(CODEX_DIR, { recursive: true });
|
|
@@ -73,6 +76,17 @@ const merged = mergeCodexHooks(existing, template);
|
|
|
73
76
|
writeJson(HOOKS_FILE, merged);
|
|
74
77
|
log.success("Merged PAL hooks into ~/.codex/hooks.json");
|
|
75
78
|
|
|
79
|
+
// --- Merge allowlist rules ---
|
|
80
|
+
mkdirSync(resolve(CODEX_DIR, "rules"), { recursive: true });
|
|
81
|
+
if (existsSync(RULES_FILE)) {
|
|
82
|
+
copyFileSync(RULES_FILE, `${RULES_FILE}.bak.${Date.now()}`);
|
|
83
|
+
log.info("Backed up rules/default.rules");
|
|
84
|
+
}
|
|
85
|
+
const rulesTemplate = loadCodexRulesTemplate(assets.codexRulesTemplate());
|
|
86
|
+
const existingRules = existsSync(RULES_FILE) ? readFileSync(RULES_FILE, "utf-8") : "";
|
|
87
|
+
writeFileSync(RULES_FILE, mergeCodexRules(existingRules, rulesTemplate), "utf-8");
|
|
88
|
+
log.success("Merged PAL allowlist rules into ~/.codex/rules/default.rules");
|
|
89
|
+
|
|
76
90
|
// --- Symlink skills to ~/.codex/skills/ ---
|
|
77
91
|
const codexSkillsDir = resolve(CODEX_DIR, "skills");
|
|
78
92
|
copySkills(codexSkillsDir);
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
readJson,
|
|
14
14
|
removeSkills,
|
|
15
15
|
unmergeCodexHooks,
|
|
16
|
+
unmergeCodexRules,
|
|
16
17
|
writeJson,
|
|
17
18
|
} from "../lib";
|
|
18
19
|
|
|
@@ -38,6 +39,7 @@ function disableCodexHooks(configPath: string): void {
|
|
|
38
39
|
const PKG_ROOT = palPkg().replaceAll("\\", "/");
|
|
39
40
|
const CODEX_DIR = platform.codexDir();
|
|
40
41
|
const HOOKS_FILE = resolve(CODEX_DIR, "hooks.json");
|
|
42
|
+
const RULES_FILE = resolve(CODEX_DIR, "rules", "default.rules");
|
|
41
43
|
|
|
42
44
|
// --- Remove PAL hooks from hooks.json ---
|
|
43
45
|
if (existsSync(HOOKS_FILE)) {
|
|
@@ -54,6 +56,18 @@ if (existsSync(HOOKS_FILE)) {
|
|
|
54
56
|
log.info("No hooks.json found, nothing to do");
|
|
55
57
|
}
|
|
56
58
|
|
|
59
|
+
// --- Remove PAL allowlist rules from default.rules ---
|
|
60
|
+
if (existsSync(RULES_FILE)) {
|
|
61
|
+
copyFileSync(RULES_FILE, `${RULES_FILE}.bak.${Date.now()}`);
|
|
62
|
+
log.info("Backed up rules/default.rules");
|
|
63
|
+
|
|
64
|
+
const cleanedRules = unmergeCodexRules(readFileSync(RULES_FILE, "utf-8"));
|
|
65
|
+
writeFileSync(RULES_FILE, cleanedRules, "utf-8");
|
|
66
|
+
log.success("Removed PAL allowlist rules from ~/.codex/rules/default.rules");
|
|
67
|
+
} else {
|
|
68
|
+
log.info("No default.rules found, nothing to do");
|
|
69
|
+
}
|
|
70
|
+
|
|
57
71
|
// --- Remove PAL skill symlinks ---
|
|
58
72
|
const codexSkillsDir = resolve(CODEX_DIR, "skills");
|
|
59
73
|
const removed = removeSkills(codexSkillsDir);
|
package/src/targets/lib.ts
CHANGED
|
@@ -273,22 +273,22 @@ export function loadCodexHooksTemplate(
|
|
|
273
273
|
}
|
|
274
274
|
}
|
|
275
275
|
|
|
276
|
-
/**
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
result.hooks ??= {};
|
|
281
|
-
|
|
282
|
-
// Collect canonical paths of PAL template commands so we can evict stale variants
|
|
283
|
-
const palCanonical = new Set(
|
|
284
|
-
Object.values(template.hooks).flatMap((groups) =>
|
|
276
|
+
/** Collect canonical command paths from a Codex hooks template (PAL-managed commands). */
|
|
277
|
+
function collectPalCanonical(template: CodexHooks): Set<string> {
|
|
278
|
+
return new Set(
|
|
279
|
+
Object.values(template.hooks ?? {}).flatMap((groups) =>
|
|
285
280
|
groups.flatMap((g) => g.hooks.map((h) => canonicalCmd(h.command)))
|
|
286
281
|
)
|
|
287
282
|
);
|
|
283
|
+
}
|
|
288
284
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
285
|
+
/** Strip entries (nested or flat) whose canonical command matches a PAL-managed command. */
|
|
286
|
+
function stripPalHooks(
|
|
287
|
+
hooks: Record<string, CodexHookGroup[]>,
|
|
288
|
+
palCanonical: Set<string>
|
|
289
|
+
): void {
|
|
290
|
+
for (const event of Object.keys(hooks)) {
|
|
291
|
+
hooks[event] = (hooks[event] ?? [])
|
|
292
292
|
.map((g) => {
|
|
293
293
|
const flat = g as unknown as CodexHookCommand;
|
|
294
294
|
if (!g.hooks && flat.command && palCanonical.has(canonicalCmd(flat.command))) {
|
|
@@ -300,10 +300,18 @@ export function mergeCodexHooks(existing: CodexHooks, template: CodexHooks): Cod
|
|
|
300
300
|
return filtered.length > 0 ? { ...g, hooks: filtered } : null;
|
|
301
301
|
})
|
|
302
302
|
.filter((g): g is CodexHookGroup => g !== null);
|
|
303
|
-
if (
|
|
303
|
+
if (hooks[event].length === 0) delete hooks[event];
|
|
304
304
|
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Merge PAL hooks into an existing Codex hooks.json. Deduplicates by canonical command path. */
|
|
308
|
+
export function mergeCodexHooks(existing: CodexHooks, template: CodexHooks): CodexHooks {
|
|
309
|
+
const result: CodexHooks = { ...existing };
|
|
310
|
+
if (!template.hooks) return result;
|
|
311
|
+
result.hooks ??= {};
|
|
312
|
+
|
|
313
|
+
stripPalHooks(result.hooks, collectPalCanonical(template));
|
|
305
314
|
|
|
306
|
-
// Add fresh template entries
|
|
307
315
|
for (const [event, groups] of Object.entries(template.hooks)) {
|
|
308
316
|
const current = result.hooks[event] ?? [];
|
|
309
317
|
for (const group of groups) current.push(group);
|
|
@@ -320,32 +328,41 @@ export function unmergeCodexHooks(
|
|
|
320
328
|
const result: CodexHooks = { ...existing };
|
|
321
329
|
if (!template.hooks || !result.hooks) return result;
|
|
322
330
|
|
|
323
|
-
|
|
324
|
-
const palCanonical = new Set(
|
|
325
|
-
Object.values(template.hooks).flatMap((groups) =>
|
|
326
|
-
groups.flatMap((g) => g.hooks.map((h) => canonicalCmd(h.command)))
|
|
327
|
-
)
|
|
328
|
-
);
|
|
329
|
-
|
|
330
|
-
for (const event of Object.keys(result.hooks)) {
|
|
331
|
-
result.hooks[event] = (result.hooks[event] ?? [])
|
|
332
|
-
.map((g) => {
|
|
333
|
-
const flat = g as unknown as CodexHookCommand;
|
|
334
|
-
if (!g.hooks && flat.command && palCanonical.has(canonicalCmd(flat.command))) {
|
|
335
|
-
return null;
|
|
336
|
-
}
|
|
337
|
-
const filtered = (g.hooks ?? []).filter(
|
|
338
|
-
(h) => !palCanonical.has(canonicalCmd(h.command))
|
|
339
|
-
);
|
|
340
|
-
return filtered.length > 0 ? { ...g, hooks: filtered } : null;
|
|
341
|
-
})
|
|
342
|
-
.filter((g): g is CodexHookGroup => g !== null);
|
|
343
|
-
if (result.hooks[event].length === 0) delete result.hooks[event];
|
|
344
|
-
}
|
|
331
|
+
stripPalHooks(result.hooks, collectPalCanonical(template));
|
|
345
332
|
if (Object.keys(result.hooks).length === 0) delete result.hooks;
|
|
346
333
|
return result;
|
|
347
334
|
}
|
|
348
335
|
|
|
336
|
+
// --- Codex rules (Starlark .rules file) ---
|
|
337
|
+
|
|
338
|
+
const CODEX_RULES_BEGIN = "# BEGIN PAL MANAGED CODEX RULES";
|
|
339
|
+
const CODEX_RULES_END = "# END PAL MANAGED CODEX RULES";
|
|
340
|
+
|
|
341
|
+
export function loadCodexRulesTemplate(templatePath: string): string {
|
|
342
|
+
return readFileSync(templatePath, "utf-8").trim();
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function stripPalCodexRules(content: string): string {
|
|
346
|
+
const escapedBegin = CODEX_RULES_BEGIN.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
347
|
+
const escapedEnd = CODEX_RULES_END.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
348
|
+
const block = new RegExp(String.raw`\n?${escapedBegin}[\s\S]*?${escapedEnd}\n?`, "g");
|
|
349
|
+
return content
|
|
350
|
+
.replace(block, "\n")
|
|
351
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
352
|
+
.trim();
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export function mergeCodexRules(existing: string, template: string): string {
|
|
356
|
+
const preserved = stripPalCodexRules(existing);
|
|
357
|
+
const prefix = preserved ? `${preserved}\n\n` : "";
|
|
358
|
+
return `${prefix}${template.trim()}\n`;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export function unmergeCodexRules(existing: string): string {
|
|
362
|
+
const cleaned = stripPalCodexRules(existing);
|
|
363
|
+
return cleaned ? `${cleaned}\n` : "";
|
|
364
|
+
}
|
|
365
|
+
|
|
349
366
|
// --- TELOS scaffolding ---
|
|
350
367
|
|
|
351
368
|
/** Copy template files into telos/ without overwriting existing ones */
|