portable-agent-layer 0.1.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 (90) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +80 -0
  3. package/assets/agents/claude-researcher.md +43 -0
  4. package/assets/agents/investigative-researcher.md +44 -0
  5. package/assets/agents/multi-perspective-researcher.md +43 -0
  6. package/assets/skills/analyze-pdf.md +40 -0
  7. package/assets/skills/analyze-youtube.md +35 -0
  8. package/assets/skills/council.md +43 -0
  9. package/assets/skills/create-skill.md +31 -0
  10. package/assets/skills/extract-entities.md +63 -0
  11. package/assets/skills/extract-wisdom.md +18 -0
  12. package/assets/skills/first-principles.md +17 -0
  13. package/assets/skills/fyzz-chat-api.md +43 -0
  14. package/assets/skills/reflect.md +87 -0
  15. package/assets/skills/research.md +68 -0
  16. package/assets/skills/review.md +19 -0
  17. package/assets/skills/summarize.md +15 -0
  18. package/assets/templates/AGENTS.md.template +45 -0
  19. package/assets/templates/telos/BELIEFS.md +4 -0
  20. package/assets/templates/telos/CHALLENGES.md +4 -0
  21. package/assets/templates/telos/GOALS.md +12 -0
  22. package/assets/templates/telos/IDEAS.md +4 -0
  23. package/assets/templates/telos/IDENTITY.md +4 -0
  24. package/assets/templates/telos/LEARNED.md +4 -0
  25. package/assets/templates/telos/MISSION.md +4 -0
  26. package/assets/templates/telos/MODELS.md +4 -0
  27. package/assets/templates/telos/NARRATIVES.md +4 -0
  28. package/assets/templates/telos/PROJECTS.md +7 -0
  29. package/assets/templates/telos/STRATEGIES.md +4 -0
  30. package/bin/pal +24 -0
  31. package/bin/pal.bat +8 -0
  32. package/bin/pal.ps1 +30 -0
  33. package/package.json +82 -0
  34. package/src/cli/index.ts +344 -0
  35. package/src/cli/install.ts +86 -0
  36. package/src/cli/uninstall.ts +45 -0
  37. package/src/hooks/LoadContext.ts +41 -0
  38. package/src/hooks/SecurityValidator.ts +52 -0
  39. package/src/hooks/SkillGuard.ts +41 -0
  40. package/src/hooks/StopOrchestrator.ts +35 -0
  41. package/src/hooks/UserPromptOrchestrator.ts +35 -0
  42. package/src/hooks/handlers/backup.ts +41 -0
  43. package/src/hooks/handlers/failure.ts +136 -0
  44. package/src/hooks/handlers/rating.ts +409 -0
  45. package/src/hooks/handlers/relationship.ts +113 -0
  46. package/src/hooks/handlers/session-name.ts +121 -0
  47. package/src/hooks/handlers/synthesis.ts +109 -0
  48. package/src/hooks/handlers/tab.ts +8 -0
  49. package/src/hooks/handlers/update-counts.ts +151 -0
  50. package/src/hooks/handlers/work-learning.ts +183 -0
  51. package/src/hooks/handlers/work-session.ts +58 -0
  52. package/src/hooks/lib/claude-md.ts +121 -0
  53. package/src/hooks/lib/context.ts +433 -0
  54. package/src/hooks/lib/entities.ts +304 -0
  55. package/src/hooks/lib/export.ts +76 -0
  56. package/src/hooks/lib/inference.ts +91 -0
  57. package/src/hooks/lib/learning-category.ts +14 -0
  58. package/src/hooks/lib/log.ts +53 -0
  59. package/src/hooks/lib/models.ts +16 -0
  60. package/src/hooks/lib/paths.ts +80 -0
  61. package/src/hooks/lib/relationship.ts +135 -0
  62. package/src/hooks/lib/security.ts +122 -0
  63. package/src/hooks/lib/session-names.ts +247 -0
  64. package/src/hooks/lib/setup.ts +189 -0
  65. package/src/hooks/lib/signal-trends.ts +117 -0
  66. package/src/hooks/lib/signals.ts +37 -0
  67. package/src/hooks/lib/stdin.ts +18 -0
  68. package/src/hooks/lib/stop.ts +155 -0
  69. package/src/hooks/lib/time.ts +19 -0
  70. package/src/hooks/lib/token-usage.ts +42 -0
  71. package/src/hooks/lib/transcript.ts +76 -0
  72. package/src/hooks/lib/wisdom.ts +48 -0
  73. package/src/hooks/lib/work-tracking.ts +193 -0
  74. package/src/hooks/setup-check.ts +42 -0
  75. package/src/targets/claude/install.ts +145 -0
  76. package/src/targets/claude/uninstall.ts +101 -0
  77. package/src/targets/lib.ts +337 -0
  78. package/src/targets/opencode/install.ts +59 -0
  79. package/src/targets/opencode/plugin.ts +328 -0
  80. package/src/targets/opencode/uninstall.ts +57 -0
  81. package/src/tools/entity-save.ts +110 -0
  82. package/src/tools/export.ts +34 -0
  83. package/src/tools/fyzz-api.ts +104 -0
  84. package/src/tools/import.ts +123 -0
  85. package/src/tools/pattern-synthesis.ts +435 -0
  86. package/src/tools/pdf-download.ts +102 -0
  87. package/src/tools/relationship-reflect.ts +362 -0
  88. package/src/tools/session-summary.ts +206 -0
  89. package/src/tools/token-cost.ts +301 -0
  90. package/src/tools/youtube-analyze.ts +105 -0
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Compute rating averages from ratings.jsonl, cache in signal-cache.json.
3
+ * Returns today / this-week / this-month averages + trend direction.
4
+ */
5
+
6
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
7
+ import { resolve } from "node:path";
8
+ import { paths } from "./paths";
9
+
10
+ interface RatingSignal {
11
+ ts: string;
12
+ rating: number;
13
+ }
14
+
15
+ interface SignalCache {
16
+ computed_at: string;
17
+ today: number | null;
18
+ week: number | null;
19
+ month: number | null;
20
+ trend: "up" | "down" | "stable" | null;
21
+ }
22
+
23
+ function cacheFilePath(): string {
24
+ return resolve(paths.state(), "signal-cache.json");
25
+ }
26
+
27
+ function avg(nums: number[]): number | null {
28
+ if (nums.length === 0) return null;
29
+ return Math.round((nums.reduce((a, b) => a + b, 0) / nums.length) * 10) / 10;
30
+ }
31
+
32
+ function trendDirection(
33
+ week: number | null,
34
+ month: number | null
35
+ ): "up" | "down" | "stable" | null {
36
+ if (week === null || month === null) return null;
37
+ if (week > month + 0.5) return "up";
38
+ if (week < month - 0.5) return "down";
39
+ return "stable";
40
+ }
41
+
42
+ /** Read ratings.jsonl and compute trend stats, with 10-minute cache. */
43
+ export function computeSignalTrends(): SignalCache {
44
+ const cachePath = cacheFilePath();
45
+
46
+ // Return cached value if fresh (< 10 minutes old)
47
+ if (existsSync(cachePath)) {
48
+ try {
49
+ const cache = JSON.parse(readFileSync(cachePath, "utf-8")) as SignalCache;
50
+ const age = Date.now() - new Date(cache.computed_at).getTime();
51
+ if (age < 10 * 60 * 1000) return cache;
52
+ } catch {
53
+ // Recompute
54
+ }
55
+ }
56
+
57
+ const ratingsPath = resolve(paths.signals(), "ratings.jsonl");
58
+ if (!existsSync(ratingsPath)) {
59
+ const empty: SignalCache = {
60
+ computed_at: new Date().toISOString(),
61
+ today: null,
62
+ week: null,
63
+ month: null,
64
+ trend: null,
65
+ };
66
+ writeFileSync(cachePath, JSON.stringify(empty, null, 2), "utf-8");
67
+ return empty;
68
+ }
69
+
70
+ const now = new Date();
71
+ const todayStart = new Date(now.toISOString().slice(0, 10)).getTime();
72
+ const weekAgo = now.getTime() - 7 * 24 * 60 * 60 * 1000;
73
+ const monthAgo = now.getTime() - 30 * 24 * 60 * 60 * 1000;
74
+
75
+ const todayRatings: number[] = [];
76
+ const weekRatings: number[] = [];
77
+ const monthRatings: number[] = [];
78
+
79
+ for (const line of readFileSync(ratingsPath, "utf-8").split("\n")) {
80
+ if (!line.trim()) continue;
81
+ try {
82
+ const signal = JSON.parse(line) as RatingSignal;
83
+ if (typeof signal.rating !== "number") continue;
84
+ const ts = new Date(signal.ts).getTime();
85
+ if (ts >= monthAgo) monthRatings.push(signal.rating);
86
+ if (ts >= weekAgo) weekRatings.push(signal.rating);
87
+ if (ts >= todayStart) todayRatings.push(signal.rating);
88
+ } catch {
89
+ // Skip malformed lines
90
+ }
91
+ }
92
+
93
+ const week = avg(weekRatings);
94
+ const month = avg(monthRatings);
95
+ const result: SignalCache = {
96
+ computed_at: new Date().toISOString(),
97
+ today: avg(todayRatings),
98
+ week,
99
+ month,
100
+ trend: trendDirection(week, month),
101
+ };
102
+
103
+ writeFileSync(cachePath, JSON.stringify(result, null, 2), "utf-8");
104
+ return result;
105
+ }
106
+
107
+ /** Format signal trends as a short markdown string for system-reminder injection */
108
+ export function formatTrends(cache: SignalCache): string {
109
+ if (cache.today === null && cache.week === null) return "";
110
+
111
+ const parts: string[] = [];
112
+ if (cache.today !== null) parts.push(`today: ${cache.today}/10`);
113
+ if (cache.week !== null) parts.push(`7d avg: ${cache.week}/10`);
114
+ if (cache.trend) parts.push(`trend: ${cache.trend}`);
115
+
116
+ return `**Signal trends** — ${parts.join(" | ")}`;
117
+ }
@@ -0,0 +1,37 @@
1
+ import { appendFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { paths } from "./paths";
4
+ import { now } from "./time";
5
+
6
+ export interface Signal {
7
+ ts: string;
8
+ type: string;
9
+ [key: string]: unknown;
10
+ }
11
+
12
+ /** Append a signal to a JSONL file in the signals directory */
13
+ export function emitSignal(
14
+ filename: string,
15
+ data: { type: string; [key: string]: unknown }
16
+ ): void {
17
+ const signal: Signal = { ts: now(), ...data };
18
+ const filepath = resolve(paths.signals(), filename);
19
+ appendFileSync(filepath, `${JSON.stringify(signal)}\n`);
20
+ }
21
+
22
+ /** Append a rating signal */
23
+ export function emitRating(
24
+ rating: number,
25
+ context: string,
26
+ source: string = "explicit",
27
+ responsePreview?: string
28
+ ): void {
29
+ const data = {
30
+ type: "rating",
31
+ rating,
32
+ context,
33
+ source,
34
+ ...(responsePreview ? { response_preview: responsePreview } : {}),
35
+ };
36
+ emitSignal("ratings.jsonl", data);
37
+ }
@@ -0,0 +1,18 @@
1
+ /** Read all of stdin as a string */
2
+ export async function readStdin(): Promise<string> {
3
+ const chunks: Buffer[] = [];
4
+ for await (const chunk of Bun.stdin.stream()) {
5
+ chunks.push(Buffer.from(chunk));
6
+ }
7
+ return Buffer.concat(chunks).toString("utf-8");
8
+ }
9
+
10
+ /** Read stdin and parse as JSON, returning null on failure */
11
+ export async function readStdinJSON<T = unknown>(): Promise<T | null> {
12
+ try {
13
+ const raw = await readStdin();
14
+ return JSON.parse(raw) as T;
15
+ } catch {
16
+ return null;
17
+ }
18
+ }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Shared stop handlers - runs all stop event logic.
3
+ * Used by StopOrchestrator.ts (Claude Code) and opencode plugin.
4
+ */
5
+
6
+ import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
7
+ import { resolve } from "node:path";
8
+ import { autoBackup } from "../handlers/backup";
9
+ import { captureFailure } from "../handlers/failure";
10
+ import { captureRelationship } from "../handlers/relationship";
11
+ import { checkSynthesisTrigger } from "../handlers/synthesis";
12
+ import { resetTab } from "../handlers/tab";
13
+ import { updateCounts } from "../handlers/update-counts";
14
+ import { captureWorkLearning } from "../handlers/work-learning";
15
+ import { captureWorkSession } from "../handlers/work-session";
16
+ import { logDebug, logError } from "./log";
17
+ import { ensureDir, paths } from "./paths";
18
+ import { extractContent, extractLastAssistant, parseMessages } from "./transcript";
19
+
20
+ export interface RunStopHandlersOptions {
21
+ lastAssistantMessage?: string;
22
+ sessionId?: string;
23
+ }
24
+
25
+ /** Run all stop handlers with a transcript string */
26
+ export async function runStopHandlers(
27
+ transcript: string,
28
+ options: RunStopHandlersOptions = {}
29
+ ): Promise<void> {
30
+ const messages = parseMessages(transcript);
31
+ if (messages.length < 2) return;
32
+
33
+ logDebug("runStopHandlers", `Running handlers (${messages.length} messages)`);
34
+
35
+ // Cache last assistant response (session-scoped)
36
+ cacheLastResponse(messages, options.lastAssistantMessage, options.sessionId);
37
+
38
+ // Run all handlers concurrently (manual wisdom extraction only - no automatic extraction)
39
+ const results = await Promise.allSettled([
40
+ captureWorkSession(transcript, options.sessionId),
41
+ resetTab(),
42
+ captureRelationship(transcript, options.sessionId),
43
+ captureWorkLearning(transcript, options.sessionId),
44
+ checkPendingFailure(transcript),
45
+ updateCounts(),
46
+ autoBackup(),
47
+ checkSynthesisTrigger(),
48
+ ]);
49
+
50
+ const handlerNames = [
51
+ "work-session",
52
+ "tab",
53
+ "relationship",
54
+ "work-learning",
55
+ "pending-failure",
56
+ "update-counts",
57
+ "backup",
58
+ "synthesis",
59
+ ];
60
+ for (let i = 0; i < results.length; i++) {
61
+ const r = results[i];
62
+ if (r.status === "rejected") {
63
+ logError(`runStopHandlers:${handlerNames[i]}`, r.reason);
64
+ }
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Cache the last assistant response per session for RatingCapture.
70
+ * Stores a map of session_id → { response, ts } in last-responses.json.
71
+ * Keeps at most MAX_CACHED_SESSIONS entries (evicts oldest by ts).
72
+ */
73
+ const MAX_CACHED_SESSIONS = 20;
74
+
75
+ interface CachedResponse {
76
+ response: string;
77
+ ts: string;
78
+ }
79
+
80
+ function cacheLastResponse(
81
+ msgs: ReturnType<typeof parseMessages>,
82
+ lastAssistantMessage?: string,
83
+ sessionId?: string
84
+ ): void {
85
+ if (!sessionId) return; // Can't cache without a session key
86
+
87
+ try {
88
+ let lastResponse = lastAssistantMessage;
89
+ if (!lastResponse) {
90
+ const lastAssistant = extractLastAssistant(msgs);
91
+ lastResponse = extractContent(lastAssistant);
92
+ }
93
+ if (!lastResponse) return;
94
+
95
+ const cachePath = resolve(ensureDir(paths.state()), "last-responses.json");
96
+
97
+ // Read existing cache
98
+ let cache: Record<string, CachedResponse> = {};
99
+ if (existsSync(cachePath)) {
100
+ try {
101
+ cache = JSON.parse(readFileSync(cachePath, "utf-8"));
102
+ } catch {
103
+ cache = {};
104
+ }
105
+ }
106
+
107
+ // Upsert this session
108
+ cache[sessionId] = {
109
+ response: lastResponse.slice(0, 2000),
110
+ ts: new Date().toISOString(),
111
+ };
112
+
113
+ // Evict oldest if over limit
114
+ const keys = Object.keys(cache);
115
+ if (keys.length > MAX_CACHED_SESSIONS) {
116
+ const sorted = keys.sort((a, b) =>
117
+ (cache[a].ts ?? "").localeCompare(cache[b].ts ?? "")
118
+ );
119
+ for (const key of sorted.slice(0, keys.length - MAX_CACHED_SESSIONS)) {
120
+ delete cache[key];
121
+ }
122
+ }
123
+
124
+ writeFileSync(cachePath, JSON.stringify(cache), "utf-8");
125
+ logDebug("runStopHandlers", "Cached last response for RatingCapture");
126
+ } catch (err) {
127
+ logError("runStopHandlers:cacheLastResponse", err);
128
+ }
129
+ }
130
+
131
+ async function checkPendingFailure(transcript: string): Promise<void> {
132
+ const pendingPath = resolve(paths.state(), "pending-failure.json");
133
+ if (!existsSync(pendingPath)) return;
134
+
135
+ try {
136
+ const pending = JSON.parse(readFileSync(pendingPath, "utf-8")) as {
137
+ rating: number;
138
+ context: string;
139
+ detailedContext?: string;
140
+ responsePreview?: string;
141
+ userPreview?: string;
142
+ };
143
+ unlinkSync(pendingPath);
144
+ await captureFailure(
145
+ pending.rating,
146
+ pending.context,
147
+ transcript,
148
+ pending.detailedContext,
149
+ pending.responsePreview,
150
+ pending.userPreview
151
+ );
152
+ } catch {
153
+ // Non-critical
154
+ }
155
+ }
@@ -0,0 +1,19 @@
1
+ /** ISO 8601 UTC timestamp */
2
+ export function now(): string {
3
+ return new Date().toISOString();
4
+ }
5
+
6
+ /** Date-based path segment: YYYY/MM */
7
+ export function monthPath(): string {
8
+ const d = new Date();
9
+ return `${d.getFullYear()}/${String(d.getMonth() + 1).padStart(2, "0")}`;
10
+ }
11
+
12
+ /** Compact timestamp for filenames: YYYYMMDD-HHmmss */
13
+ export function fileTimestamp(): string {
14
+ return new Date()
15
+ .toISOString()
16
+ .replace(/[-:]/g, "")
17
+ .replace("T", "-")
18
+ .replace(/\.\d+Z/, "");
19
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Append-only JSONL logger for Haiku token usage.
3
+ * Writes to memory/signals/token-usage.jsonl
4
+ */
5
+
6
+ import { appendFileSync } from "node:fs";
7
+ import { resolve } from "node:path";
8
+ import { HAIKU_MODEL } from "./models";
9
+ import { ensureDir, paths } from "./paths";
10
+
11
+ export type TokenCaller =
12
+ | "rating"
13
+ | "failure"
14
+ | "work-learning"
15
+ | "session-name"
16
+ | "relationship";
17
+
18
+ interface TokenUsageEntry {
19
+ ts: string;
20
+ caller: TokenCaller;
21
+ model: string;
22
+ inputTokens: number;
23
+ outputTokens: number;
24
+ }
25
+
26
+ export function logTokenUsage(
27
+ caller: TokenCaller,
28
+ usage: { inputTokens: number; outputTokens: number },
29
+ model?: string
30
+ ): void {
31
+ const entry: TokenUsageEntry = {
32
+ ts: new Date().toISOString(),
33
+ caller,
34
+ model: model ?? HAIKU_MODEL,
35
+ inputTokens: usage.inputTokens,
36
+ outputTokens: usage.outputTokens,
37
+ };
38
+
39
+ const dir = ensureDir(paths.signals());
40
+ const filepath = resolve(dir, "token-usage.jsonl");
41
+ appendFileSync(filepath, `${JSON.stringify(entry)}\n`, "utf-8");
42
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Shared transcript parsing utilities.
3
+ * Used by Stop handlers and the opencode plugin.
4
+ */
5
+
6
+ import { readFileSync } from "node:fs";
7
+
8
+ export interface Message {
9
+ role: string;
10
+ content: string | unknown;
11
+ }
12
+
13
+ /** Parse raw transcript string into messages array. Returns [] on failure. */
14
+ export function parseMessages(raw: string): Message[] {
15
+ try {
16
+ const parsed = JSON.parse(raw);
17
+ return Array.isArray(parsed) ? parsed : [];
18
+ } catch {
19
+ return [];
20
+ }
21
+ }
22
+
23
+ /**
24
+ * Read a Claude Code transcript JSONL file and extract user/assistant messages.
25
+ * Each line is a JSON object; we extract entries with type "user" or "assistant".
26
+ */
27
+ export function readTranscriptFile(path: string): Message[] {
28
+ try {
29
+ const content = readFileSync(path, "utf-8");
30
+ const messages: Message[] = [];
31
+
32
+ for (const line of content.split("\n")) {
33
+ if (!line.trim()) continue;
34
+ try {
35
+ const entry = JSON.parse(line);
36
+ if (entry.type === "user" || entry.type === "assistant") {
37
+ const msg = entry.message ?? {};
38
+ let text = "";
39
+ if (typeof msg.content === "string") {
40
+ text = msg.content;
41
+ } else if (Array.isArray(msg.content)) {
42
+ text = msg.content
43
+ .filter((c: { type: string }) => c.type === "text")
44
+ .map((c: { text: string }) => c.text)
45
+ .join(" ");
46
+ }
47
+ if (text) {
48
+ messages.push({ role: entry.type, content: text });
49
+ }
50
+ }
51
+ } catch {
52
+ /* skip malformed lines */
53
+ }
54
+ }
55
+
56
+ return messages;
57
+ } catch {
58
+ return [];
59
+ }
60
+ }
61
+
62
+ /** Extract string content from a message object */
63
+ export function extractContent(msg: Message | undefined): string {
64
+ if (!msg) return "";
65
+ return typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content);
66
+ }
67
+
68
+ /** Get the last assistant message from a messages array */
69
+ export function extractLastAssistant(messages: Message[]): Message | undefined {
70
+ return messages.filter((m) => m.role === "assistant").pop();
71
+ }
72
+
73
+ /** Get the last user message from a messages array */
74
+ export function extractLastUser(messages: Message[]): Message | undefined {
75
+ return messages.filter((m) => m.role === "user").pop();
76
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Wisdom Frames — domain-specific crystallized knowledge that compounds across sessions.
3
+ *
4
+ * Frame files live at memory/wisdom/frames/{domain}.md
5
+ * Principles marked [CRYSTAL: ≥85%] are injected into every session.
6
+ *
7
+ * Frames are populated by Claude during conversations (via CLAUDE.md instructions),
8
+ * not auto-extracted from transcripts.
9
+ */
10
+
11
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
12
+ import { resolve } from "node:path";
13
+ import { paths } from "./paths";
14
+
15
+ /** Extract CRYSTAL principles (≥85% confidence) from all frame files */
16
+ export function readFramePrinciples(): string[] {
17
+ const framesDir = paths.wisdom();
18
+ const principles: string[] = [];
19
+
20
+ if (!existsSync(framesDir)) return principles;
21
+
22
+ for (const file of readdirSync(framesDir).filter((f) => f.endsWith(".md"))) {
23
+ const domain = file.replace(".md", "");
24
+ const content = readFileSync(resolve(framesDir, file), "utf-8");
25
+
26
+ // v4: headings "### Name [CRYSTAL: N%]"
27
+ for (const match of content.matchAll(/^### (.+?) \[CRYSTAL:\s*(\d+)%\]/gm)) {
28
+ const name = match[1]?.trim();
29
+ const pct = parseInt(match[2] ?? "", 10);
30
+ if (name && Number.isFinite(pct) && pct >= 85) {
31
+ principles.push(`[${domain}] ${name} (${pct}%)`);
32
+ }
33
+ }
34
+
35
+ // legacy fallback: bullet lines "- X [CRYSTAL: N%]"
36
+ for (const line of content.split("\n")) {
37
+ const match = line.match(/^\s*-\s*(.+?)\s*\[CRYSTAL:\s*(\d+)%\]\s*$/);
38
+ if (!match) continue;
39
+ const name = match[1]?.trim();
40
+ const pct = parseInt(match[2] ?? "", 10);
41
+ if (name && Number.isFinite(pct) && pct >= 85) {
42
+ principles.push(`[${domain}] ${name} (${pct}%)`);
43
+ }
44
+ }
45
+ }
46
+
47
+ return principles;
48
+ }