portable-agent-layer 0.64.0 → 0.65.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.
@@ -50,14 +50,20 @@ export function writeJson(path: string, data: unknown): void {
50
50
  /** Public PAL repository — the link surfaced in commit/PR co-author credits. */
51
51
  export const PAL_REPO_URL = "https://github.com/kovrichard/portable-agent-layer";
52
52
 
53
- /** Build the commit footer (bare URL, autolinks on GitHub) and PR body line (markdown link). */
53
+ /**
54
+ * Build the commit footer (bare URL, autolinks on GitHub) and PR body line
55
+ * (markdown link). `sessionUrl` is off because Claude Code otherwise appends a
56
+ * claude.ai session link to commits made from web or Remote Control sessions,
57
+ * which puts a link to a private transcript in a public history.
58
+ */
54
59
  export function buildAttributionText(
55
60
  name: string,
56
61
  repoUrl: string = PAL_REPO_URL
57
- ): { commit: string; pr: string } {
62
+ ): { commit: string; pr: string; sessionUrl: false } {
58
63
  return {
59
64
  commit: `Co-authored by ${name} · ${repoUrl}`,
60
65
  pr: `Co-authored by [${name}](${repoUrl})`,
66
+ sessionUrl: false,
61
67
  };
62
68
  }
63
69
 
@@ -75,7 +81,7 @@ export function applyAttribution(
75
81
  result.attribution = buildAttributionText(opts.name, opts.repoUrl);
76
82
  result.includeCoAuthoredBy = false;
77
83
  } else {
78
- result.attribution = { commit: "", pr: "" };
84
+ result.attribution = { commit: "", pr: "", sessionUrl: false };
79
85
  if (result.includeCoAuthoredBy === false) delete result.includeCoAuthoredBy;
80
86
  }
81
87
  return result;
@@ -693,8 +699,8 @@ export function scaffoldPalSettings(): void {
693
699
 
694
700
  // --- PAL docs (modular context routing files) ---
695
701
 
696
- const PAL_DOCS_DIR = resolve(palHome(), "docs");
697
- const PAL_TOOLS_DIR = resolve(palHome(), "tools");
702
+ const palDocsDir = () => resolve(palHome(), "docs");
703
+ const palToolsDir = () => resolve(palHome(), "tools");
698
704
 
699
705
  /**
700
706
  * Install PAL system docs into ~/.pal/docs/.
@@ -705,19 +711,19 @@ export function copyPalDocs(): number {
705
711
  const srcDir = assets.palDocs();
706
712
  if (!existsSync(srcDir)) return 0;
707
713
 
708
- mkdirSync(PAL_DOCS_DIR, { recursive: true });
714
+ mkdirSync(palDocsDir(), { recursive: true });
709
715
  let count = 0;
710
716
 
711
717
  for (const file of readdirSync(srcDir).filter((f) => f.endsWith(".md"))) {
712
718
  const src = resolve(srcDir, file);
713
- const dst = resolve(PAL_DOCS_DIR, file);
719
+ const dst = resolve(palDocsDir(), file);
714
720
  copyFileSync(src, dst);
715
721
  count++;
716
722
  }
717
723
 
718
724
  // ~/.pal/tools/ → repo agent tools
719
725
  const linkType = process.platform === "win32" ? "junction" : "dir";
720
- ensureSymlink(PAL_TOOLS_DIR, assets.agentTools(), linkType);
726
+ ensureSymlink(palToolsDir(), assets.agentTools(), linkType);
721
727
 
722
728
  return count;
723
729
  }
@@ -726,13 +732,13 @@ export function copyPalDocs(): number {
726
732
  export function removePalDocs(): void {
727
733
  // Remove tools symlink
728
734
  try {
729
- unlinkSync(PAL_TOOLS_DIR);
735
+ unlinkSync(palToolsDir());
730
736
  } catch {
731
737
  /* gone */
732
738
  }
733
- if (!existsSync(PAL_DOCS_DIR)) return;
739
+ if (!existsSync(palDocsDir())) return;
734
740
  try {
735
- rmSync(PAL_DOCS_DIR, { recursive: true });
741
+ rmSync(palDocsDir(), { recursive: true });
736
742
  log.info("Removed ~/.pal/docs/");
737
743
  } catch {
738
744
  /* gone */
@@ -741,7 +747,7 @@ export function removePalDocs(): void {
741
747
 
742
748
  // --- Skills ---
743
749
 
744
- const PAL_SKILLS_DIR = resolve(palHome(), "skills");
750
+ const palSkillsDir = () => resolve(palHome(), "skills");
745
751
 
746
752
  /**
747
753
  * Run one step of a bulk install, naming it only when it fails.
@@ -772,7 +778,7 @@ export function copySkills(claudeSkillsDir: string): number {
772
778
  const skillsDir = assets.skills();
773
779
  if (!existsSync(skillsDir)) return 0;
774
780
 
775
- mkdirSync(PAL_SKILLS_DIR, { recursive: true });
781
+ mkdirSync(palSkillsDir(), { recursive: true });
776
782
  mkdirSync(claudeSkillsDir, { recursive: true });
777
783
  const linkType = process.platform === "win32" ? "junction" : "dir";
778
784
  let count = 0;
@@ -785,7 +791,7 @@ export function copySkills(claudeSkillsDir: string): number {
785
791
  const srcDir = resolve(skillsDir, name);
786
792
  if (!existsSync(resolve(srcDir, "SKILL.md"))) continue;
787
793
 
788
- const palLink = resolve(PAL_SKILLS_DIR, name);
794
+ const palLink = resolve(palSkillsDir(), name);
789
795
  const claudeLink = resolve(claudeSkillsDir, name);
790
796
  const linked = reportOnlyOnFailure(`skill ${name}`, () => {
791
797
  // ~/.pal/skills/<name> → <repo>/assets/skills/<name>
@@ -798,7 +804,7 @@ export function copySkills(claudeSkillsDir: string): number {
798
804
 
799
805
  // ~/.agents/skills/ → ~/.pal/skills/
800
806
  mkdirSync(platform.agentsDir(), { recursive: true });
801
- ensureSymlink(resolve(platform.agentsDir(), "skills"), PAL_SKILLS_DIR, linkType);
807
+ ensureSymlink(resolve(platform.agentsDir(), "skills"), palSkillsDir(), linkType);
802
808
 
803
809
  return count;
804
810
  }
@@ -836,8 +842,8 @@ function symlinkPointsInto(link: string, root: string): boolean {
836
842
  */
837
843
  function pruneStaleSkillLinks(agentSkillsDir: string): string[] {
838
844
  const ownedTrees = [
839
- { dir: PAL_SKILLS_DIR, root: assets.skills() },
840
- { dir: agentSkillsDir, root: PAL_SKILLS_DIR },
845
+ { dir: palSkillsDir(), root: assets.skills() },
846
+ { dir: agentSkillsDir, root: palSkillsDir() },
841
847
  ];
842
848
  const removed: string[] = [];
843
849
  for (const { dir, root } of ownedTrees) {
@@ -878,7 +884,7 @@ function perSkillAgentDirs(): { agent: string; dir: string }[] {
878
884
  * is covered by the whole-dir ~/.agents/skills link).
879
885
  */
880
886
  export function linkPersonalSkill(name: string): string[] {
881
- const palLink = resolve(PAL_SKILLS_DIR, name);
887
+ const palLink = resolve(palSkillsDir(), name);
882
888
  if (!existsSync(resolve(palLink, "SKILL.md"))) {
883
889
  throw new Error(`No skill found at ${palLink}/SKILL.md`);
884
890
  }
@@ -892,8 +898,47 @@ export function linkPersonalSkill(name: string): string[] {
892
898
  return linked;
893
899
  }
894
900
 
901
+ /**
902
+ * The agent config trees PAL writes into when no env override is set. These are
903
+ * the developer's own installed agents, so a test that forgets to point the
904
+ * PAL_*_DIR vars at a sandbox silently rewires their real setup.
905
+ */
906
+ function realAgentRoots(): string[] {
907
+ const h = homedir();
908
+ return [
909
+ resolve(h, ".pal"),
910
+ resolve(h, ".claude"),
911
+ resolve(h, ".cursor"),
912
+ resolve(h, ".copilot"),
913
+ resolve(h, ".codex"),
914
+ resolve(h, ".agents"),
915
+ resolve(h, ".config", "opencode"),
916
+ ];
917
+ }
918
+
919
+ /**
920
+ * Under `bun test` (PAL_TEST_SANDBOX, set by the test preload and inherited by
921
+ * spawned CLIs), refuse any link that would land in a real agent tree. Tests
922
+ * sandbox PAL_HOME far more reliably than they sandbox the per-agent dirs, and
923
+ * the failure is otherwise invisible: the suite passes while the developer's
924
+ * own agents accumulate links into a deleted test directory.
925
+ */
926
+ function assertInsideTestSandbox(link: string): void {
927
+ if (!process.env.PAL_TEST_SANDBOX) return;
928
+ const escaped = realAgentRoots().find(
929
+ (root) => link === root || link.startsWith(root + sep)
930
+ );
931
+ if (!escaped) return;
932
+ throw new Error(
933
+ `Refusing to write ${link}: outside the test sandbox (${escaped} is a real agent directory). ` +
934
+ "Point PAL_CLAUDE_DIR, PAL_CURSOR_DIR, PAL_COPILOT_DIR, PAL_CODEX_DIR, " +
935
+ "PAL_OPENCODE_DIR and PAL_AGENTS_DIR at a temp directory in this test."
936
+ );
937
+ }
938
+
895
939
  /** Create or update a symlink/junction, replacing any non-symlink entry. */
896
940
  function ensureSymlink(link: string, target: string, type: "dir" | "junction"): void {
941
+ assertInsideTestSandbox(link);
897
942
  try {
898
943
  const st = lstatSync(link);
899
944
  if (st.isSymbolicLink()) return; // already a symlink, leave it
@@ -941,7 +986,7 @@ export function removeSkills(claudeSkillsDir: string): string[] {
941
986
  for (const name of readdirSync(skillsDir)) {
942
987
  if (!existsSync(resolve(skillsDir, name, "SKILL.md"))) continue;
943
988
 
944
- for (const link of [resolve(PAL_SKILLS_DIR, name), resolve(claudeSkillsDir, name)]) {
989
+ for (const link of [resolve(palSkillsDir(), name), resolve(claudeSkillsDir, name)]) {
945
990
  try {
946
991
  unlinkSync(link);
947
992
  } catch {
@@ -964,14 +1009,14 @@ export function removeSkills(claudeSkillsDir: string): string[] {
964
1009
 
965
1010
  // --- Agents ---
966
1011
 
967
- const CLAUDE_AGENTS_DIR = resolve(platform.claudeDir(), "agents");
1012
+ const claudeAgentsDir = () => resolve(platform.claudeDir(), "agents");
968
1013
 
969
1014
  /**
970
1015
  * Install PAL agent definitions into ~/.claude/agents/.
971
1016
  * Always overwrites — engine-managed, not user-editable.
972
1017
  */
973
1018
  export function copyAgents(): number {
974
- return installAgents(CLAUDE_AGENTS_DIR, "claude");
1019
+ return installAgents(claudeAgentsDir(), "claude");
975
1020
  }
976
1021
 
977
1022
  /** Remove PAL agents from ~/.claude/agents/ */
@@ -981,7 +1026,7 @@ export function removeAgents(): string[] {
981
1026
 
982
1027
  const removed: string[] = [];
983
1028
  for (const file of readdirSync(agentsDir).filter((f) => f.endsWith(".md"))) {
984
- const dst = resolve(CLAUDE_AGENTS_DIR, file);
1029
+ const dst = resolve(claudeAgentsDir(), file);
985
1030
  if (existsSync(dst)) {
986
1031
  unlinkSync(dst);
987
1032
  const name = file.replace(/\.md$/, "");
@@ -994,9 +1039,9 @@ export function removeAgents(): string[] {
994
1039
 
995
1040
  /** Count agent .md files in ~/.claude/agents/ */
996
1041
  export function countAgents(): number {
997
- if (!existsSync(CLAUDE_AGENTS_DIR)) return 0;
1042
+ if (!existsSync(claudeAgentsDir())) return 0;
998
1043
  try {
999
- return readdirSync(CLAUDE_AGENTS_DIR).filter((f) => f.endsWith(".md")).length;
1044
+ return readdirSync(claudeAgentsDir()).filter((f) => f.endsWith(".md")).length;
1000
1045
  } catch {
1001
1046
  return 0;
1002
1047
  }
@@ -1132,7 +1177,7 @@ export function removeAgentsFromCopilot(copilotAgentsDir: string): string[] {
1132
1177
  * Store for user-authored subagents: ~/.pal/agents/<name>.md — one merged
1133
1178
  * multi-platform frontmatter file per subagent (same schema as assets/agents/).
1134
1179
  */
1135
- const PAL_AGENTS_STORE = resolve(palHome(), "agents");
1180
+ const palAgentsStore = () => resolve(palHome(), "agents");
1136
1181
 
1137
1182
  /**
1138
1183
  * Each installed agent and the native agents directory a personal subagent is
@@ -1161,8 +1206,8 @@ function shippedAgentNames(): Set<string> {
1161
1206
 
1162
1207
  /** List the user-authored subagents in ~/.pal/agents/. */
1163
1208
  export function listPersonalSubagents(): string[] {
1164
- if (!existsSync(PAL_AGENTS_STORE)) return [];
1165
- return readdirSync(PAL_AGENTS_STORE)
1209
+ if (!existsSync(palAgentsStore())) return [];
1210
+ return readdirSync(palAgentsStore())
1166
1211
  .filter((f) => f.endsWith(".md"))
1167
1212
  .map((f) => f.replace(/\.md$/, ""))
1168
1213
  .sort();
@@ -1176,7 +1221,7 @@ export function listPersonalSubagents(): string[] {
1176
1221
  * its own frontmatter shape. Returns the agents it was installed into.
1177
1222
  */
1178
1223
  export function installPersonalSubagent(name: string): string[] {
1179
- const src = resolve(PAL_AGENTS_STORE, `${name}.md`);
1224
+ const src = resolve(palAgentsStore(), `${name}.md`);
1180
1225
  if (!existsSync(src)) {
1181
1226
  throw new Error(`No subagent found at ${src}`);
1182
1227
  }
@@ -1408,7 +1453,7 @@ function extractTriggers(description: string): string[] {
1408
1453
  * Called during install after skills are symlinked.
1409
1454
  */
1410
1455
  export function generateSkillIndex(): number {
1411
- if (!existsSync(PAL_SKILLS_DIR)) return 0;
1456
+ if (!existsSync(palSkillsDir())) return 0;
1412
1457
 
1413
1458
  const index: SkillIndex = {
1414
1459
  generated: new Date().toISOString(),
@@ -1416,8 +1461,8 @@ export function generateSkillIndex(): number {
1416
1461
  skills: {},
1417
1462
  };
1418
1463
 
1419
- for (const name of readdirSync(PAL_SKILLS_DIR)) {
1420
- const skillMd = resolve(PAL_SKILLS_DIR, name, "SKILL.md");
1464
+ for (const name of readdirSync(palSkillsDir())) {
1465
+ const skillMd = resolve(palSkillsDir(), name, "SKILL.md");
1421
1466
  if (!existsSync(skillMd)) continue;
1422
1467
 
1423
1468
  try {
@@ -1456,10 +1501,10 @@ export function generateSkillIndex(): number {
1456
1501
 
1457
1502
  /** Count skill subdirectories in ~/.pal/skills/ */
1458
1503
  export function countSkills(): number {
1459
- if (!existsSync(PAL_SKILLS_DIR)) return 0;
1504
+ if (!existsSync(palSkillsDir())) return 0;
1460
1505
  try {
1461
- return readdirSync(PAL_SKILLS_DIR).filter((f) =>
1462
- existsSync(resolve(PAL_SKILLS_DIR, f, "SKILL.md"))
1506
+ return readdirSync(palSkillsDir()).filter((f) =>
1507
+ existsSync(resolve(palSkillsDir(), f, "SKILL.md"))
1463
1508
  ).length;
1464
1509
  } catch {
1465
1510
  return 0;
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env bun
2
+
2
3
  /**
3
4
  * AlgorithmReflect — Append structured algorithm reflections to JSONL.
4
5
  *
@@ -14,6 +15,8 @@
14
15
 
15
16
  import { appendFileSync } from "node:fs";
16
17
  import { parseArgs } from "node:util";
18
+ import { encodeAnchor } from "../../hooks/lib/anchor";
19
+ import { loadMachine } from "../../hooks/lib/machine";
17
20
  import { paths } from "../../hooks/lib/paths";
18
21
  import { emit } from "../lib/emit";
19
22
 
@@ -22,6 +25,7 @@ import { emit } from "../lib/emit";
22
25
  interface AlgorithmReflection {
23
26
  timestamp: string;
24
27
  cwd: string;
28
+ m: string;
25
29
  task: string;
26
30
  criteria_count: number;
27
31
  criteria_passed: number;
@@ -41,6 +45,40 @@ function reflectionsPath(): string {
41
45
  return paths.reflectionsFile();
42
46
  }
43
47
 
48
+ /**
49
+ * Assemble a reflection record from CLI-style input, stamping the current
50
+ * cwd (anchored) and this machine's id. Exported so the stamping logic is
51
+ * directly testable without going through argv parsing.
52
+ */
53
+ export function buildReflection(input: {
54
+ task: string;
55
+ q1: string;
56
+ q2: string;
57
+ q3: string;
58
+ criteria_count?: number;
59
+ criteria_passed?: number;
60
+ criteria_failed?: number;
61
+ sentiment?: number;
62
+ scope?: string;
63
+ }): AlgorithmReflection {
64
+ return {
65
+ timestamp: new Date().toISOString(),
66
+ cwd: encodeAnchor(process.cwd()),
67
+ m: loadMachine().id,
68
+ task: input.task,
69
+ criteria_count: input.criteria_count ?? 0,
70
+ criteria_passed: input.criteria_passed ?? 0,
71
+ criteria_failed: input.criteria_failed ?? 0,
72
+ sentiment: Math.max(1, Math.min(10, input.sentiment ?? 5)),
73
+ q1: input.q1,
74
+ q2: input.q2,
75
+ q3: input.q3,
76
+ // Default to general (the ~94% case); only "task-specific" suppresses it
77
+ // from algorithm-update clustering.
78
+ scope: input.scope === "task-specific" ? "task-specific" : "general",
79
+ };
80
+ }
81
+
44
82
  function appendReflection(reflection: AlgorithmReflection): {
45
83
  success: boolean;
46
84
  message: string;
@@ -105,21 +143,17 @@ Output: algorithm-reflections.jsonl in memory/learning/reflections/
105
143
  process.exit(1);
106
144
  }
107
145
 
108
- const reflection: AlgorithmReflection = {
109
- timestamp: new Date().toISOString(),
110
- cwd: process.cwd(),
146
+ const reflection = buildReflection({
111
147
  task: values.task,
112
- criteria_count: parseInt(values.criteria || "0", 10),
113
- criteria_passed: parseInt(values.passed || "0", 10),
114
- criteria_failed: parseInt(values.failed || "0", 10),
115
- sentiment: Math.max(1, Math.min(10, parseInt(values.sentiment || "5", 10))),
116
148
  q1: values.q1,
117
149
  q2: values.q2,
118
150
  q3: values.q3,
119
- // Default to general (the ~94% case); only "task-specific" suppresses it
120
- // from algorithm-update clustering.
121
- scope: values.scope === "task-specific" ? "task-specific" : "general",
122
- };
151
+ criteria_count: parseInt(values.criteria || "0", 10),
152
+ criteria_passed: parseInt(values.passed || "0", 10),
153
+ criteria_failed: parseInt(values.failed || "0", 10),
154
+ sentiment: parseInt(values.sentiment || "5", 10),
155
+ scope: values.scope,
156
+ });
123
157
 
124
158
  const result = appendReflection(reflection);
125
159
  emit.ok(result.message);