portable-agent-layer 0.42.0 → 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.
@@ -0,0 +1,21 @@
1
+ import type { CodeSample } from "@/lib/types";
2
+
3
+ interface CodeBlockProps {
4
+ sample: CodeSample;
5
+ }
6
+
7
+ export async function CodeBlock({ sample }: CodeBlockProps) {
8
+ return (
9
+ <div className="my-4 break-inside-avoid">
10
+ <div className="font-sans text-[0.65rem] font-bold uppercase tracking-widest text-primary mb-1">
11
+ {sample.language}
12
+ </div>
13
+ <pre className="rounded-lg overflow-x-auto text-[0.8rem] leading-relaxed px-5 py-4 bg-[#f6f8fa]">
14
+ <code>{sample.code}</code>
15
+ </pre>
16
+ {sample.caption && (
17
+ <p className="text-[0.78rem] text-muted italic mt-1">{sample.caption}</p>
18
+ )}
19
+ </div>
20
+ );
21
+ }
@@ -1,46 +1,107 @@
1
+ interface MetadataItem {
2
+ label: string;
3
+ value: string;
4
+ }
5
+
1
6
  interface CoverPageProps {
2
- clientName: string;
3
7
  reportTitle: string;
4
8
  reportDate: string;
5
- classification: string;
9
+ clientName: string;
6
10
  consultancyName: string;
7
11
  preTitle?: string;
12
+ /** Path to consultancy logo image. Falls back to consultancyName text wordmark. */
13
+ consultancyLogoSrc?: string;
14
+ /** Path to client logo image. Falls back to clientName text wordmark. */
15
+ clientLogoSrc?: string;
16
+ /** Optional metadata rows rendered in the bottom strip (e.g. classification, version). */
17
+ metadata?: MetadataItem[];
18
+ }
19
+
20
+ function LogoSlot({
21
+ src,
22
+ name,
23
+ align = "left",
24
+ }: {
25
+ src?: string;
26
+ name: string;
27
+ align?: "left" | "right";
28
+ }) {
29
+ if (src) {
30
+ return (
31
+ // biome-ignore lint/performance/noImgElement: Template reports are static exports; next/image is not needed for print logos.
32
+ <img
33
+ src={src}
34
+ alt={name}
35
+ className="h-10 w-auto object-contain"
36
+ style={{ maxWidth: 180 }}
37
+ />
38
+ );
39
+ }
40
+ return (
41
+ <span
42
+ className={`font-sans text-sm font-bold uppercase tracking-widest text-foreground ${
43
+ align === "right" ? "text-right" : ""
44
+ }`}
45
+ >
46
+ {name}
47
+ </span>
48
+ );
8
49
  }
9
50
 
10
51
  export function CoverPage({
11
- clientName,
12
52
  reportTitle,
13
- reportDate,
14
- classification,
53
+ clientName,
15
54
  consultancyName,
16
- preTitle = "Strategic Assessment",
55
+ preTitle,
56
+ consultancyLogoSrc,
57
+ clientLogoSrc,
58
+ metadata,
17
59
  }: CoverPageProps) {
18
60
  return (
19
- <div
20
- className={[
21
- "min-h-screen flex flex-col justify-center p-16",
22
- "break-after-page",
23
- "bg-gradient-to-b from-background to-background-secondary",
24
- ].join(" ")}
25
- >
26
- <div className="font-sans text-sm font-semibold text-destructive uppercase tracking-[0.15em] mb-16">
27
- {classification}
61
+ <div className="min-h-screen flex flex-col break-after-page bg-background">
62
+ {/* Logo strip */}
63
+ <div className="flex items-center justify-between px-16 py-8 border-b-2 border-primary">
64
+ <LogoSlot src={consultancyLogoSrc} name={consultancyName} align="left" />
65
+ <LogoSlot src={clientLogoSrc} name={clientName} align="right" />
28
66
  </div>
29
67
 
30
- <div className="text-sm tracking-[0.25em] text-primary uppercase mb-4 font-semibold font-sans">
31
- {preTitle}
68
+ {/* Main content vertically centered */}
69
+ <div className="flex-1 flex flex-col justify-center px-16 py-20">
70
+ {preTitle && (
71
+ <p className="font-sans text-xs font-bold uppercase tracking-widest text-primary mb-5">
72
+ {preTitle}
73
+ </p>
74
+ )}
75
+ <h1 className="font-heading text-5xl font-semibold text-foreground leading-tight mb-6 tracking-tight max-w-2xl">
76
+ {reportTitle}
77
+ </h1>
78
+ <p className="font-heading text-xl text-muted font-normal">
79
+ Prepared for {clientName}
80
+ </p>
32
81
  </div>
33
- <h1 className="font-heading text-5xl font-semibold text-foreground leading-tight mb-4 tracking-tight">
34
- {reportTitle}
35
- </h1>
36
- <p className="font-heading text-2xl text-muted mb-16 font-normal">
37
- Prepared for {clientName}
38
- </p>
39
82
 
40
- <div className="mt-auto pt-6 border-t border-border">
41
- <p className="font-sans text-base text-muted">{reportDate}</p>
42
- <p className="text-muted-dark text-sm mt-2 font-sans">{consultancyName}</p>
43
- </div>
83
+ {/* Metadata strip */}
84
+ {metadata && metadata.length > 0 && (
85
+ <div className="border-t border-border px-16 py-8">
86
+ <dl
87
+ className="grid gap-x-12 gap-y-4"
88
+ style={{
89
+ gridTemplateColumns: `repeat(${Math.min(metadata.length, 4)}, auto) 1fr`,
90
+ }}
91
+ >
92
+ {metadata.map((item) => (
93
+ <div key={item.label}>
94
+ <dt className="font-sans text-[0.62rem] font-bold uppercase tracking-widest text-muted mb-1">
95
+ {item.label}
96
+ </dt>
97
+ <dd className="font-sans text-sm font-medium text-foreground">
98
+ {item.value}
99
+ </dd>
100
+ </div>
101
+ ))}
102
+ </dl>
103
+ </div>
104
+ )}
44
105
  </div>
45
106
  );
46
107
  }
@@ -0,0 +1,62 @@
1
+ import type { Decision } from "@/lib/types";
2
+
3
+ interface DecisionTableProps {
4
+ decisions: Decision[];
5
+ intro?: string;
6
+ }
7
+
8
+ function StatusBadge({ status }: { status: Decision["status"] }) {
9
+ if (status === "adopt-default") {
10
+ return (
11
+ <span className="inline-block px-2 py-0.5 rounded font-sans text-[0.65rem] font-bold uppercase tracking-widest bg-success/10 text-success border border-success/30">
12
+ Adopt default
13
+ </span>
14
+ );
15
+ }
16
+ return (
17
+ <span className="inline-block px-2 py-0.5 rounded font-sans text-[0.65rem] font-bold uppercase tracking-widest bg-warning/10 text-warning border border-warning/30">
18
+ Confirm at sync
19
+ </span>
20
+ );
21
+ }
22
+
23
+ export function DecisionTable({ decisions, intro }: DecisionTableProps) {
24
+ const thClass =
25
+ "font-sans text-[0.68rem] font-bold uppercase tracking-widest text-primary px-3 py-2 border-b-2 border-primary text-left";
26
+ const tdClass = "px-3 py-2.5 border-b border-border-subtle align-top";
27
+
28
+ return (
29
+ <div className="my-4">
30
+ {intro && <p className="text-[0.9375rem] mb-4">{intro}</p>}
31
+ <table className="w-full border-collapse font-body text-[0.85rem]">
32
+ <thead>
33
+ <tr>
34
+ <th className={`${thClass} w-[4%]`}>#</th>
35
+ <th className={`${thClass} w-[28%]`}>Decision</th>
36
+ <th className={`${thClass} w-[44%]`}>Recommended default</th>
37
+ <th className={`${thClass} w-[24%]`}>Status</th>
38
+ </tr>
39
+ </thead>
40
+ <tbody>
41
+ {decisions.map((d, i) => (
42
+ <tr key={d.id} className="break-inside-avoid">
43
+ <td className={`${tdClass} text-muted font-sans text-[0.78rem]`}>
44
+ {i + 1}
45
+ </td>
46
+ <td className={tdClass}>
47
+ <div className="font-sans font-semibold text-foreground">{d.title}</div>
48
+ {d.description && (
49
+ <div className="text-muted text-[0.82rem] mt-0.5">{d.description}</div>
50
+ )}
51
+ </td>
52
+ <td className={`${tdClass} text-muted`}>{d.recommendedDefault}</td>
53
+ <td className={tdClass}>
54
+ <StatusBadge status={d.status} />
55
+ </td>
56
+ </tr>
57
+ ))}
58
+ </tbody>
59
+ </table>
60
+ </div>
61
+ );
62
+ }
@@ -0,0 +1,28 @@
1
+ import type { ProcessStage } from "@/lib/types";
2
+
3
+ interface ProcessStageProps {
4
+ stage: ProcessStage;
5
+ }
6
+
7
+ export function ProcessStageBlock({ stage }: ProcessStageProps) {
8
+ return (
9
+ <div className="mb-6 break-inside-avoid">
10
+ <div className="font-sans font-semibold text-primary text-xs uppercase tracking-widest mb-3">
11
+ {stage.name}
12
+ </div>
13
+ <ol className="list-decimal list-outside ml-5 space-y-2">
14
+ {stage.items.map((item) => (
15
+ <li
16
+ key={`${item.text}:${item.note ?? ""}`}
17
+ className="text-[0.9375rem] text-foreground pl-1"
18
+ >
19
+ {item.text}
20
+ {item.note && (
21
+ <span className="ml-1 text-muted text-[0.82rem] italic">({item.note})</span>
22
+ )}
23
+ </li>
24
+ ))}
25
+ </ol>
26
+ </div>
27
+ );
28
+ }
@@ -0,0 +1,102 @@
1
+ import type { CSSProperties } from "react";
2
+ import type { TierMatrixCellValue, TierMatrixRow } from "@/lib/types";
3
+
4
+ interface TierMatrixProps {
5
+ columns: string[];
6
+ rows: TierMatrixRow[];
7
+ caption?: string;
8
+ /** Alignment for data cells. Defaults to "center" for symbol matrices; use "left" for text-heavy tables. */
9
+ alignCells?: "left" | "center";
10
+ /**
11
+ * Column sizing mode:
12
+ * - "auto" (default): browser distributes space by content — no explicit widths applied.
13
+ * - "capped": columnWidths[i] applied as max-width — content drives size up to the cap.
14
+ * - "manual": columnWidths[i] applied as explicit width.
15
+ * columnWidths is parallel to ALL columns: [0] = layer column, [1..n] = data columns.
16
+ */
17
+ sizing?: "auto" | "capped" | "manual";
18
+ columnWidths?: string[];
19
+ }
20
+
21
+ function CellContent({ value }: { value: TierMatrixCellValue }) {
22
+ if (value === "required") {
23
+ return <span className="text-success font-bold text-base">✓</span>;
24
+ }
25
+ if (value === "recommended") {
26
+ return (
27
+ <span className="font-sans text-[0.7rem] font-semibold uppercase tracking-widest text-warning">
28
+ rec.
29
+ </span>
30
+ );
31
+ }
32
+ if (value === false) {
33
+ return <span className="text-muted">—</span>;
34
+ }
35
+ return <span>{value}</span>;
36
+ }
37
+
38
+ export function TierMatrix({
39
+ columns,
40
+ rows,
41
+ caption,
42
+ alignCells = "center",
43
+ sizing = "auto",
44
+ columnWidths,
45
+ }: TierMatrixProps) {
46
+ const thClass =
47
+ "font-sans text-[0.68rem] font-bold uppercase tracking-widest text-primary px-3 py-2 border-b-2 border-primary";
48
+ const tdClass = "px-3 py-2 border-b border-border-subtle align-middle";
49
+ const colHeaderAlign = alignCells === "left" ? "text-left" : "text-center";
50
+ const cellAlign = alignCells === "left" ? "text-left" : "text-center";
51
+
52
+ function colStyle(index: number): CSSProperties | undefined {
53
+ const val = columnWidths?.[index];
54
+ if (!val) return undefined;
55
+ if (sizing === "manual") return { width: val };
56
+ if (sizing === "capped") return { maxWidth: val };
57
+ return undefined;
58
+ }
59
+
60
+ return (
61
+ <div className="my-4">
62
+ <table className="w-full border-collapse font-body text-[0.85rem] break-inside-avoid">
63
+ <thead>
64
+ <tr>
65
+ <th className={`${thClass} text-left`} style={colStyle(0)}>
66
+ Layer
67
+ </th>
68
+ {columns.map((col, i) => (
69
+ <th
70
+ key={col}
71
+ className={`${thClass} ${colHeaderAlign}`}
72
+ style={colStyle(i + 1)}
73
+ >
74
+ {col}
75
+ </th>
76
+ ))}
77
+ </tr>
78
+ </thead>
79
+ <tbody>
80
+ {rows.map((row) => (
81
+ <tr key={`${row.layer}:${row.cells.join("|")}`}>
82
+ <td
83
+ className={`${tdClass} text-left font-sans font-medium text-foreground`}
84
+ >
85
+ {row.layer}
86
+ </td>
87
+ {row.cells.map((cell, i) => (
88
+ <td
89
+ key={`${columns[i] ?? "cell"}:${String(cell)}`}
90
+ className={`${tdClass} ${cellAlign}`}
91
+ >
92
+ <CellContent value={cell} />
93
+ </td>
94
+ ))}
95
+ </tr>
96
+ ))}
97
+ </tbody>
98
+ </table>
99
+ {caption && <p className="text-[0.78rem] text-muted italic mt-1">{caption}</p>}
100
+ </div>
101
+ );
102
+ }
@@ -4,7 +4,7 @@
4
4
  // project (the report instance that supplies the actual data). Projects import
5
5
  // the types they need; they do NOT edit this file.
6
6
  //
7
- // Two families of types:
7
+ // Three families of types:
8
8
  //
9
9
  // 1. Strategic-assessment shapes — Finding, Recommendation, TimelinePhase,
10
10
  // and the ReportData interface composed of them. These power the default
@@ -14,6 +14,10 @@
14
14
  // power rubric-style deliverables (an opportunity scoring playbook is one
15
15
  // example). Each is optional; projects use whichever apply.
16
16
  //
17
+ // 3. Process-guide shapes — TierMatrixRow, ProcessStage, CodeSample, Decision.
18
+ // Power process documentation deliverables (tiered requirements matrices,
19
+ // stage-by-stage process flows, technical appendices, sign-off tables).
20
+ //
17
21
  // Adding a new section type? Add its interface here so all reports share the
18
22
  // same vocabulary.
19
23
 
@@ -188,3 +192,47 @@ export interface TuningLogEntry {
188
192
  rationale: string;
189
193
  approver: string;
190
194
  }
195
+
196
+ // --- Process-guide shapes ---
197
+
198
+ /** Generic N-column checklist matrix (e.g. requirements by tier/level). */
199
+ export type TierMatrixCellValue = "required" | "recommended" | false | string;
200
+ export interface TierMatrixRow {
201
+ /** Row label (e.g. "Formatter", "Secret scan"). */
202
+ layer: string;
203
+ /** One value per column, parallel to the `columns` prop on TierMatrix. */
204
+ cells: TierMatrixCellValue[];
205
+ }
206
+
207
+ /** One item inside a ProcessStage. */
208
+ export interface ProcessStageItem {
209
+ text: string;
210
+ /** Optional inline cross-reference, e.g. "see §4.6". Renders muted. */
211
+ note?: string;
212
+ }
213
+
214
+ /** A named process stage with ordered action items. */
215
+ export interface ProcessStage {
216
+ id: string;
217
+ name: string;
218
+ items: ProcessStageItem[];
219
+ }
220
+
221
+ /** A code sample for a technical appendix. */
222
+ export interface CodeSample {
223
+ id: string;
224
+ title: string;
225
+ /** Language identifier shown as a badge, e.g. "yaml", "typescript", "markdown". */
226
+ language: string;
227
+ caption?: string;
228
+ code: string;
229
+ }
230
+
231
+ /** A single row in a sign-off or open-decisions table. */
232
+ export interface Decision {
233
+ id: string;
234
+ title: string;
235
+ description?: string;
236
+ recommendedDefault: string;
237
+ status: "adopt-default" | "confirm-at-sync";
238
+ }
@@ -12,11 +12,12 @@
12
12
  // node --experimental-strip-types ~/.pal/skills/consulting-report/tools/generate-pdf.ts <report-dir> [--pdf <out>] [--html <out>] [--skip-build]
13
13
 
14
14
  import { spawnSync } from "node:child_process";
15
- import { createReadStream, constants as fsConstants } from "node:fs";
16
- import { access, stat } from "node:fs/promises";
15
+ import { createReadStream, constants as fsConstants, realpathSync } from "node:fs";
16
+ import { access, readFile, stat, unlink, writeFile } from "node:fs/promises";
17
17
  import { createServer, type Server } from "node:http";
18
18
  import { extname, join, resolve } from "node:path";
19
19
  import { pathToFileURL } from "node:url";
20
+ import { PDFDocument } from "pdf-lib";
20
21
  import { chromium } from "playwright";
21
22
 
22
23
  interface ReportMeta {
@@ -24,6 +25,10 @@ interface ReportMeta {
24
25
  reportTitle: string;
25
26
  classification: string;
26
27
  consultancyName: string;
28
+ /** Public path to consultancy logo (e.g. "/logos/konvert7.svg"). Used in the PDF footer. */
29
+ consultancyLogoSrc?: string;
30
+ /** Public path to client logo (e.g. "/logos/transcend.svg"). Used in the PDF header. */
31
+ clientLogoSrc?: string;
27
32
  }
28
33
 
29
34
  const COLOR = {
@@ -56,6 +61,25 @@ function slugify(s: string): string {
56
61
  .replace(/^-|-$/g, "");
57
62
  }
58
63
 
64
+ /** Reads a logo file from the static export and returns a base64 data URI, or null if not found. */
65
+ async function logoDataUri(
66
+ outDir: string,
67
+ publicPath: string | undefined
68
+ ): Promise<string | null> {
69
+ if (!publicPath) return null;
70
+ const filePath = join(outDir, publicPath);
71
+ if (!(await exists(filePath))) return null;
72
+ const buf = await readFile(filePath);
73
+ const ext = extname(filePath).toLowerCase();
74
+ let mime = "image/jpeg";
75
+ if (ext === ".svg") {
76
+ mime = "image/svg+xml";
77
+ } else if (ext === ".png") {
78
+ mime = "image/png";
79
+ }
80
+ return `data:${mime};base64,${buf.toString("base64")}`;
81
+ }
82
+
59
83
  async function loadMeta(reportDir: string): Promise<ReportMeta> {
60
84
  const dataPath = join(reportDir, "lib", "report-data.ts");
61
85
  if (!(await exists(dataPath))) {
@@ -150,29 +174,79 @@ async function renderPdf(
150
174
  );
151
175
  });
152
176
 
177
+ // Load logos as base64 data URIs — Playwright's header/footer templates
178
+ // run in an isolated context and cannot load external or local-path URLs.
179
+ const [clientUri, consultancyUri] = await Promise.all([
180
+ logoDataUri(outDir, meta.clientLogoSrc),
181
+ logoDataUri(outDir, meta.consultancyLogoSrc),
182
+ ]);
183
+
184
+ const clientSlot = clientUri
185
+ ? `<img src="${clientUri}" style="height:18px; width:auto; object-fit:contain; display:block;">`
186
+ : `<span style="font-weight:600; color:${COLOR.navy}; letter-spacing:0.05em;">${escapeHtml(meta.clientName.toUpperCase())}</span>`;
187
+
188
+ const consultancySlot = consultancyUri
189
+ ? `<img src="${consultancyUri}" style="height:14px; width:auto; object-fit:contain; display:block;">`
190
+ : `<span style="color:${COLOR.navy};">${escapeHtml(meta.consultancyName)}</span>`;
191
+
153
192
  const header = `
154
193
  <div style="width:100%; font-family:Inter,'Helvetica Neue',Arial,sans-serif; font-size:7.5pt; padding:0 0.7in; display:flex; justify-content:space-between; align-items:center;">
155
- <span style="font-weight:600; color:${COLOR.navy}; letter-spacing:0.05em;">${escapeHtml(meta.clientName.toUpperCase())}</span>
194
+ ${clientSlot}
156
195
  <span style="color:#94a3b8;">${escapeHtml(meta.reportTitle)}</span>
157
196
  </div>`;
158
197
 
198
+ // Footer: consultancy logo left, page number right — no classification (cover carries it)
159
199
  const footer = `
160
200
  <div style="width:100%; font-family:Inter,'Helvetica Neue',Arial,sans-serif; font-size:7.5pt; padding:0 0.7in; display:flex; justify-content:space-between; align-items:center;">
161
- <span style="color:${COLOR.red}; font-weight:600; letter-spacing:0.05em;">${escapeHtml(meta.classification)}</span>
162
- <span style="color:${COLOR.navy};">${escapeHtml(meta.consultancyName)}</span>
201
+ ${consultancySlot}
163
202
  <span style="color:${COLOR.navy};"><span class="pageNumber"></span></span>
164
203
  </div>`;
165
204
 
166
- await page.pdf({
167
- path: pdfPath,
168
- format: "A4",
169
- printBackground: true,
170
- displayHeaderFooter: true,
171
- headerTemplate: header,
172
- footerTemplate: footer,
173
- margin: { top: "0.7in", right: "0.7in", bottom: "0.7in", left: "0.7in" },
174
- preferCSSPageSize: false,
175
- });
205
+ const margin = { top: "0.7in", right: "0.7in", bottom: "0.7in", left: "0.7in" };
206
+
207
+ // Render cover (page 1) and body (pages 2+) separately so the cover has
208
+ // no header/footer and full-bleed margins, then merge with pdf-lib.
209
+ const tmpCover = `${pdfPath}.cover.tmp.pdf`;
210
+ const tmpBody = `${pdfPath}.body.tmp.pdf`;
211
+
212
+ await Promise.all([
213
+ page.pdf({
214
+ path: tmpCover,
215
+ format: "A4",
216
+ pageRanges: "1",
217
+ printBackground: true,
218
+ displayHeaderFooter: false,
219
+ margin: { top: "0", right: "0", bottom: "0", left: "0" },
220
+ preferCSSPageSize: false,
221
+ }),
222
+ page.pdf({
223
+ path: tmpBody,
224
+ format: "A4",
225
+ pageRanges: "2-",
226
+ printBackground: true,
227
+ displayHeaderFooter: true,
228
+ headerTemplate: header,
229
+ footerTemplate: footer,
230
+ margin,
231
+ preferCSSPageSize: false,
232
+ }),
233
+ ]);
234
+
235
+ // Merge: use body PDF as the base so its named destinations and link
236
+ // annotations stay intact. Insert the cover page at position 0.
237
+ const [coverBytes, bodyBytes] = await Promise.all([
238
+ readFile(tmpCover),
239
+ readFile(tmpBody),
240
+ ]);
241
+ const [coverDoc, bodyDoc] = await Promise.all([
242
+ PDFDocument.load(coverBytes),
243
+ PDFDocument.load(bodyBytes),
244
+ ]);
245
+ const [coverPage] = await bodyDoc.copyPages(coverDoc, [0]);
246
+ bodyDoc.insertPage(0, coverPage);
247
+ await writeFile(pdfPath, await bodyDoc.save());
248
+
249
+ await Promise.all([unlink(tmpCover), unlink(tmpBody)]);
176
250
  } finally {
177
251
  await browser.close();
178
252
  server.close();
@@ -236,9 +310,18 @@ async function run(argv: string[] = process.argv.slice(2)): Promise<void> {
236
310
  }
237
311
 
238
312
  // Node ≥ 22.6 doesn't expose import.meta.main; gate on argv[1] instead.
313
+ // Use realpathSync on both sides so symlinked skill paths (e.g. ~/.pal/skills →
314
+ // PAL repo) match the resolved import.meta.url.
315
+ function realResolve(p: string): string {
316
+ try {
317
+ return realpathSync(resolve(p));
318
+ } catch {
319
+ return resolve(p);
320
+ }
321
+ }
239
322
  const isMain =
240
323
  process.argv[1] &&
241
- resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname);
324
+ realResolve(process.argv[1]) === realResolve(new URL(import.meta.url).pathname);
242
325
  if (isMain) {
243
326
  await run();
244
327
  }
@@ -4,7 +4,7 @@ description: Personal context management. Use when discussing goals, beliefs, ch
4
4
  argument-hint: [area to view or update]
5
5
  ---
6
6
 
7
- Manage the user's TELOS files — the persistent personal context that drives PAL.
7
+ Manage the user's TELOS files — from Greek τέλος (télos), meaning end/purpose/goal. The persistent personal context that orients PAL around who the user is and what they're working toward.
8
8
 
9
9
  ## TELOS Files
10
10
 
@@ -38,7 +38,7 @@ Thinking-only. No tool calls except context recovery (Grep/Glob/Read).
38
38
  **0.5. ISA context** — before reverse engineering, orient against the ISA:
39
39
 
40
40
  ```bash
41
- # If cwd matches a registered project — read its open ISCs:
41
+ # If cwd matches a registered project — read its open ISCs (Ideal State Criteria):
42
42
  bun ~/.pal/tools/project.ts list-isc <project-name>
43
43
 
44
44
  # If this is ad-hoc work with no registered project — scaffold a task ISA:
@@ -85,7 +85,7 @@ Persistent storage across sessions:
85
85
  CLI utilities: `tool:opinion` (manage opinions), `tool:reflect` (relationship reflection), `tool:analyze` (learning analysis), `pal cli usage` (token usage tracking), `pal cli export` / `pal cli import` (state portability).
86
86
 
87
87
  ### TELOS (`telos/`)
88
- Personal context system — mission, goals, projects, beliefs, challenges, strategies, ideas, learnings, mental models, narratives. Managed via the telos skill.
88
+ From Greek τέλος (télos) end, purpose, goal. The persistent personal context that orients PAL around who the user is and what they're working toward: mission, goals, beliefs, challenges, strategies, ideas, learnings, mental models, narratives. Managed via the telos skill.
89
89
 
90
90
  ### Security (`SecurityValidator.ts`)
91
91
  Hook-based security: validates Bash commands and file operations against dangerous patterns. Fail-open design — blocks known-dangerous operations without breaking legitimate work.
@@ -41,7 +41,7 @@ AI agents come and go. Your accumulated knowledge, preferences, and workflows sh
41
41
  **What portability means in practice:**
42
42
  - No agent-specific assumptions in core logic
43
43
  - Agent-specific code isolated in `src/targets/`
44
- - Skills, memory, and TELOS are agent-agnostic
44
+ - Skills, memory, and TELOS (Greek: τέλος — end/purpose/goal; the user's persistent life context) are agent-agnostic
45
45
  - A single `pal cli install --<agent>` registers everything
46
46
 
47
47
  ### 2. Cross-Platform by Default
@@ -0,0 +1,64 @@
1
+ # BEGIN PAL MANAGED CODEX RULES
2
+ # Managed by PAL. Install replaces this block; uninstall removes only this block.
3
+ prefix_rule(
4
+ pattern = ["bun", "~/.pal/tools/project.ts"],
5
+ decision = "allow",
6
+ justification = "PAL project state commands are part of the installed PAL workflow",
7
+ match = [
8
+ "bun ~/.pal/tools/project.ts resume portable-agent-layer",
9
+ "bun ~/.pal/tools/project.ts list-isc portable-agent-layer",
10
+ "bun ~/.pal/tools/project.ts add-next portable-agent-layer note",
11
+ ],
12
+ not_match = [
13
+ "bun ~/.pal/tools/other.ts",
14
+ ],
15
+ )
16
+
17
+ prefix_rule(
18
+ pattern = ["bun", "~/.pal/tools/algorithm-reflect.ts"],
19
+ decision = "allow",
20
+ justification = "PAL Algorithm reflection logging is part of the installed PAL workflow",
21
+ match = [
22
+ "bun ~/.pal/tools/algorithm-reflect.ts --task work --criteria 1 --passed 1 --failed 0 --sentiment 8",
23
+ ],
24
+ not_match = [
25
+ "bun ~/.pal/tools/project.ts list",
26
+ ],
27
+ )
28
+
29
+ prefix_rule(
30
+ pattern = [
31
+ "bun",
32
+ [
33
+ "~/.pal/tools/analyze.ts",
34
+ "~/.pal/tools/handoff-note.ts",
35
+ "~/.pal/tools/relationship-note.ts",
36
+ "~/.pal/tools/synthesize.ts",
37
+ "~/.pal/tools/thread.ts",
38
+ "~/.pal/tools/wisdom-frame.ts",
39
+ ],
40
+ ],
41
+ decision = "allow",
42
+ justification = "PAL installed agent tools are safe to run without repeated approval",
43
+ match = [
44
+ "bun ~/.pal/tools/thread.ts --list",
45
+ "bun ~/.pal/tools/wisdom-frame.ts --help",
46
+ ],
47
+ not_match = [
48
+ "bun ~/.pal/tools/project.ts list",
49
+ "bun ./local-script.ts",
50
+ ],
51
+ )
52
+
53
+ prefix_rule(
54
+ pattern = ["node", "--experimental-strip-types", "~/.pal/skills/consulting-report/tools/generate-pdf.ts"],
55
+ decision = "allow",
56
+ justification = "PAL consulting-report PDF generation requires Node for Playwright compatibility",
57
+ match = [
58
+ "node --experimental-strip-types ~/.pal/skills/consulting-report/tools/generate-pdf.ts ./report",
59
+ ],
60
+ not_match = [
61
+ "node ~/.pal/skills/consulting-report/tools/generate-pdf.ts ./report",
62
+ ],
63
+ )
64
+ # END PAL MANAGED CODEX RULES
@@ -20,7 +20,8 @@
20
20
  "Bash(stat //*)",
21
21
  "Bash(readlink //*)",
22
22
  "Bash(bun ~/.pal/skills/*/tools/*.ts *)",
23
- "Bash(bun ~/.pal/tools/*.ts *)"
23
+ "Bash(bun ~/.pal/tools/*.ts *)",
24
+ "Bash(node --experimental-strip-types ~/.pal/skills/consulting-report/tools/generate-pdf.ts *)"
24
25
  ]
25
26
  },
26
27
  "hooks": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portable-agent-layer",
3
- "version": "0.42.0",
3
+ "version": "0.43.0",
4
4
  "description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
5
5
  "type": "module",
6
6
  "bin": {
@@ -82,6 +82,7 @@
82
82
  "@konvert7/klint": "0.4.0",
83
83
  "adm-zip": "^0.5.17",
84
84
  "marked": "18.0.4",
85
+ "pdf-lib": "1.17.1",
85
86
  "playwright": "^1.60.0"
86
87
  }
87
88
  }
@@ -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 (!stat.isSymbolicLink()) unlinkSync(linkPath);
58
- else return; // already a symlink, leave it
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
  }
@@ -81,6 +81,7 @@ export const assets = {
81
81
  cursorHooksTemplate: () => pkg("assets", "templates", "hooks.cursor.json"),
82
82
  copilotHooksTemplate: () => pkg("assets", "templates", "hooks.copilot.json"),
83
83
  codexHooksTemplate: () => pkg("assets", "templates", "hooks.codex.json"),
84
+ codexRulesTemplate: () => pkg("assets", "templates", "rules.codex.rules"),
84
85
  agentTools: () => pkg("src", "tools", "agent"),
85
86
  palDocs: () => pkg("assets", "templates", "PAL"),
86
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);
@@ -333,6 +333,36 @@ export function unmergeCodexHooks(
333
333
  return result;
334
334
  }
335
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
+
336
366
  // --- TELOS scaffolding ---
337
367
 
338
368
  /** Copy template files into telos/ without overwriting existing ones */