portable-agent-layer 0.75.0 → 0.76.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 (56) hide show
  1. package/assets/agents/gemini-researcher.md +1 -1
  2. package/assets/agents/grok-researcher.md +3 -3
  3. package/assets/agents/perplexity-researcher.md +1 -1
  4. package/assets/skills/analyze-pdf/SKILL.md +2 -2
  5. package/assets/skills/analyze-youtube/SKILL.md +1 -1
  6. package/assets/skills/consulting-report/SKILL.md +5 -5
  7. package/assets/skills/consulting-report/tools/dev.ts +1 -1
  8. package/assets/skills/consulting-report/tools/generate-pdf.ts +1 -1
  9. package/assets/skills/consulting-report/tools/scaffold.ts +2 -2
  10. package/assets/skills/create-pdf/SKILL.md +3 -3
  11. package/assets/skills/create-pdf/tools/md-to-html-pdf.ts +1 -1
  12. package/assets/skills/fyzz-chat-api/SKILL.md +3 -3
  13. package/assets/skills/onboarding/SKILL.md +1 -1
  14. package/assets/skills/pal-analyze/SKILL.md +1 -1
  15. package/assets/skills/pal-reflect/SKILL.md +2 -2
  16. package/assets/skills/playwright/SKILL.md +1 -1
  17. package/assets/skills/playwright/tools/shot.ts +1 -1
  18. package/assets/skills/presentation/README.md +3 -3
  19. package/assets/skills/presentation/SKILL.md +8 -8
  20. package/assets/skills/presentation/template/README.md +1 -1
  21. package/assets/skills/presentation/tools/new-deck.ts +1 -1
  22. package/assets/skills/presentation/tools/setup-template.ts +1 -1
  23. package/assets/skills/projects/SKILL.md +17 -8
  24. package/assets/skills/telos/SKILL.md +1 -1
  25. package/assets/templates/AGENTS.md.template +1 -1
  26. package/assets/templates/PAL/ALGORITHM.md +18 -18
  27. package/assets/templates/PAL/PROJECT_LIFECYCLE.md +1 -1
  28. package/assets/templates/PAL/WORK_TRACKING.md +1 -1
  29. package/assets/templates/rules.codex.rules +65 -0
  30. package/assets/templates/settings.claude.json +11 -1
  31. package/package.json +1 -1
  32. package/src/cli/builtin-tools.ts +30 -0
  33. package/src/cli/index.ts +17 -9
  34. package/src/cli/knowledge.ts +2 -1
  35. package/src/cli/skill.ts +56 -4
  36. package/src/cli/subagent.ts +1 -1
  37. package/src/hooks/lib/agent.ts +34 -3
  38. package/src/hooks/lib/bindings.ts +2 -2
  39. package/src/hooks/lib/context.ts +2 -4
  40. package/src/hooks/lib/paths.ts +48 -8
  41. package/src/hooks/lib/projects.ts +1 -1
  42. package/src/hooks/lib/which.ts +19 -12
  43. package/src/tools/agent/algorithm-reflect.ts +3 -2
  44. package/src/tools/agent/algorithm-synthesize.ts +5 -2
  45. package/src/tools/agent/analyze.ts +2 -1
  46. package/src/tools/agent/handoff-note.ts +7 -6
  47. package/src/tools/agent/project.ts +21 -20
  48. package/src/tools/agent/relationship-note.ts +11 -8
  49. package/src/tools/agent/synthesize.ts +4 -3
  50. package/src/tools/agent/thread.ts +6 -5
  51. package/src/tools/agent/wisdom-frame.ts +3 -2
  52. package/src/tools/lib/script-args.ts +4 -0
  53. package/src/tools/lib/skill-doctor.ts +2 -2
  54. package/src/tools/self-model.ts +1 -1
  55. package/src/tools/skill-doctor.ts +1 -1
  56. package/src/tools/subagent-doctor.ts +4 -4
@@ -61,4 +61,69 @@ prefix_rule(
61
61
  "node ~/.pal/skills/consulting-report/tools/generate-pdf.ts ./report",
62
62
  ],
63
63
  )
64
+
65
+ prefix_rule(
66
+ pattern = ["pal", "cli", "project"],
67
+ decision = "allow",
68
+ justification = "PAL project state commands are part of the installed PAL workflow",
69
+ match = [
70
+ "pal cli project resume portable-agent-layer",
71
+ "pal cli project list-isc portable-agent-layer",
72
+ "pal cli project add-next portable-agent-layer note",
73
+ ],
74
+ not_match = [
75
+ "pal cli install",
76
+ ],
77
+ )
78
+
79
+ prefix_rule(
80
+ pattern = ["pal", "cli", "algorithm-reflect"],
81
+ decision = "allow",
82
+ justification = "PAL Algorithm reflection logging is part of the installed PAL workflow",
83
+ match = [
84
+ "pal cli algorithm-reflect --task work --criteria 1 --passed 1 --failed 0 --sentiment 8",
85
+ ],
86
+ not_match = [
87
+ "pal cli project list",
88
+ ],
89
+ )
90
+
91
+ prefix_rule(
92
+ pattern = [
93
+ "pal",
94
+ "cli",
95
+ [
96
+ "algorithm-synthesize",
97
+ "analyze",
98
+ "handoff-note",
99
+ "relationship-note",
100
+ "synthesize",
101
+ "thread",
102
+ "wisdom-frame",
103
+ ],
104
+ ],
105
+ decision = "allow",
106
+ justification = "PAL installed agent tools are safe to run without repeated approval",
107
+ match = [
108
+ "pal cli thread --list",
109
+ "pal cli wisdom-frame --help",
110
+ ],
111
+ not_match = [
112
+ "pal cli project list",
113
+ "pal cli uninstall",
114
+ ],
115
+ )
116
+
117
+ prefix_rule(
118
+ pattern = ["pal", "cli", "skill", "run"],
119
+ decision = "allow",
120
+ justification = "A skill's own tools ship with PAL and are safe to run without repeated approval",
121
+ match = [
122
+ "pal cli skill run presentation build ./deck",
123
+ "pal cli skill run consulting-report generate-pdf.mjs ./report",
124
+ ],
125
+ not_match = [
126
+ "pal cli skill link my-skill",
127
+ ],
128
+ )
64
129
  # END PAL MANAGED CODEX RULES
@@ -34,7 +34,17 @@
34
34
  "Bash(rtk gain *)",
35
35
  "Bash(bun ~/.pal/skills/*/tools/*.ts *)",
36
36
  "Bash(bun ~/.pal/tools/*.ts *)",
37
- "Bash(node --experimental-strip-types ~/.pal/skills/consulting-report/tools/generate-pdf.ts *)"
37
+ "Bash(node --experimental-strip-types ~/.pal/skills/consulting-report/tools/generate-pdf.ts *)",
38
+ "Bash(pal cli skill run *)",
39
+ "Bash(pal cli algorithm-reflect *)",
40
+ "Bash(pal cli algorithm-synthesize *)",
41
+ "Bash(pal cli analyze *)",
42
+ "Bash(pal cli handoff-note *)",
43
+ "Bash(pal cli project *)",
44
+ "Bash(pal cli relationship-note *)",
45
+ "Bash(pal cli synthesize *)",
46
+ "Bash(pal cli thread *)",
47
+ "Bash(pal cli wisdom-frame *)"
38
48
  ]
39
49
  },
40
50
  "skillOverrides": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portable-agent-layer",
3
- "version": "0.75.0",
3
+ "version": "0.76.0",
4
4
  "description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,30 @@
1
+ /**
2
+ * PAL's own agent tools, surfaced as `pal cli <verb>`.
3
+ *
4
+ * Instruction text names the verb rather than `bun ~/.pal/tools/<tool>.ts`, so
5
+ * no shell has to expand a tilde — which cmd.exe never does and PowerShell
6
+ * passes through literally — and a tool can move without breaking every doc
7
+ * that calls it. Each entry imports lazily, so an unused verb costs nothing.
8
+ */
9
+ const BUILTIN_TOOLS = {
10
+ "algorithm-reflect": () => import("../tools/agent/algorithm-reflect"),
11
+ "algorithm-synthesize": () => import("../tools/agent/algorithm-synthesize"),
12
+ analyze: () => import("../tools/agent/analyze"),
13
+ "handoff-note": () => import("../tools/agent/handoff-note"),
14
+ project: () => import("../tools/agent/project"),
15
+ "relationship-note": () => import("../tools/agent/relationship-note"),
16
+ synthesize: () => import("../tools/agent/synthesize"),
17
+ thread: () => import("../tools/agent/thread"),
18
+ "wisdom-frame": () => import("../tools/agent/wisdom-frame"),
19
+ } satisfies Record<string, () => Promise<{ run: (argv: string[]) => unknown }>>;
20
+
21
+ export const builtinToolVerbs = Object.keys(BUILTIN_TOOLS).sort();
22
+
23
+ /** Dispatch a built-in verb; false means the command belongs to the main switch. */
24
+ export async function runBuiltinTool(command: string, args: string[]): Promise<boolean> {
25
+ const load = BUILTIN_TOOLS[command as keyof typeof BUILTIN_TOOLS];
26
+ if (!load) return false;
27
+ const { run } = await load();
28
+ await run(args);
29
+ return true;
30
+ }
package/src/cli/index.ts CHANGED
@@ -18,6 +18,8 @@
18
18
  * usage Summarize token usage and cost
19
19
  * ledger <sub> [filters] Query the action ledger (log · show · stats)
20
20
  * server start|stop|restart|status The control room, a local page over ~/.pal
21
+ * <tool> [args] Run a built-in agent tool (project, thread, analyze, …)
22
+ * skill run <skill> <tool> [-- args] Run a skill's own tool by name, not by path
21
23
  * skill link <name> Link a personal ~/.pal/skills/<name>/ into installed agents
22
24
  * skill doctor <name|--all> Evaluate one skill, or every installed skill, against the authoring best practices
23
25
  * subagent link <name> Install a personal ~/.pal/agents/<name>.md into installed agents
@@ -47,10 +49,12 @@ import {
47
49
  import { inference, previewInferenceRoute } from "../hooks/lib/inference";
48
50
  import { DEBUG_LOG_MAX_ROTATED, logDebug } from "../hooks/lib/log";
49
51
  import { ensureRegistered, writeRegistryEntry } from "../hooks/lib/machine";
50
- import { palHome, palPkg, paths, platform } from "../hooks/lib/paths";
52
+ import { palHome, palPkg, paths, platform, toPath } from "../hooks/lib/paths";
51
53
  import { auditBindings, describeBindingIssue } from "../hooks/lib/projects";
52
54
  import { telosStatus } from "../hooks/lib/telos-topics";
55
+ import { findBinaryOnPath } from "../hooks/lib/which";
53
56
  import { log } from "../targets/lib";
57
+ import { builtinToolVerbs, runBuiltinTool } from "./builtin-tools";
54
58
  import { checkPendingMigrations } from "./migrate";
55
59
 
56
60
  const allArgs = process.argv.slice(2);
@@ -184,6 +188,7 @@ async function session(sessionArgs: string[]) {
184
188
  // ── CLI dispatcher ──
185
189
 
186
190
  async function runCli(command: string | undefined, args: string[]) {
191
+ if (command && (await runBuiltinTool(command, args))) return;
187
192
  switch (command) {
188
193
  case "init":
189
194
  await init(args);
@@ -219,11 +224,6 @@ async function runCli(command: string | undefined, args: string[]) {
219
224
  runMigrate(args);
220
225
  break;
221
226
  }
222
- case "analyze": {
223
- const { run: runAnalyze } = await import("../tools/agent/analyze");
224
- await runAnalyze(args);
225
- break;
226
- }
227
227
  case "usage": {
228
228
  const { usage } = await import("../tools/token-cost");
229
229
  usage();
@@ -338,7 +338,8 @@ function showHelp() {
338
338
  pal cli status Show PAL configuration
339
339
  pal cli doctor [--probe-inference] Check prerequisites and health (--probe fires real inference per route)
340
340
  pal cli migrate [--list] [--dry-run] Run pending data migrations
341
- pal cli analyze [--actionable] Learning analysis: ratings, failure patterns, graduation candidates
341
+ pal cli <tool> [args] Run a built-in agent tool ('<tool> --help' for its flags):
342
+ ${builtinToolVerbs.join(" · ")}
342
343
  pal cli usage Summarize token usage and cost
343
344
  pal cli actor [label <name>] Show or rename this actor (who caused a record)
344
345
  pal cli machine [label <name>] Show or rename this install (where it was written)
@@ -349,6 +350,7 @@ function showHelp() {
349
350
  pal cli ledger <sub> [filters] Query the action ledger (log · show · stats)
350
351
  e.g. ledger log --project X --since 7d
351
352
  pal cli server start|stop|restart|status The control room: a local page to open before a terminal
353
+ pal cli skill run <skill> <tool> [-- args] Run ~/.pal/skills/<skill>/tools/<tool> by name
352
354
  pal cli skill link <name> Link a personal ~/.pal/skills/<name>/ into installed agents
353
355
  pal cli skill doctor <name|--all> Evaluate one skill, or every installed skill
354
356
  pal cli skill author-model Print the flagship model that authors skills for the active agent
@@ -833,6 +835,12 @@ function doctor(silent = false): DoctorResult {
833
835
  console.log("");
834
836
  log.info("Prerequisites");
835
837
  ok(`Bun ${bun.version}`);
838
+ const palBin = findBinaryOnPath("pal");
839
+ palBin
840
+ ? ok(`pal on PATH — ${palBin}`)
841
+ : fail(
842
+ "pal — not on PATH; skills and docs invoke tools as 'pal cli ...', which will not resolve. Install globally: bun add -g portable-agent-layer"
843
+ );
836
844
  const node = checkNode();
837
845
  if (!node.available) {
838
846
  warn(
@@ -1361,7 +1369,7 @@ async function exportState(args: string[]) {
1361
1369
 
1362
1370
  const dryRun = args.includes("--dry-run");
1363
1371
  const pathArg = args.find((a) => !a.startsWith("-"));
1364
- const resolvedArg = pathArg ? resolve(pathArg) : null;
1372
+ const resolvedArg = pathArg ? toPath(pathArg) : null;
1365
1373
  const argIsDir =
1366
1374
  resolvedArg !== null &&
1367
1375
  existsSync(resolvedArg) &&
@@ -1425,7 +1433,7 @@ async function importState(args: string[]) {
1425
1433
 
1426
1434
  let zipPath: string;
1427
1435
 
1428
- const resolvedArg = pathArg ? resolve(pathArg) : null;
1436
+ const resolvedArg = pathArg ? toPath(pathArg) : null;
1429
1437
  const argIsDir =
1430
1438
  resolvedArg !== null &&
1431
1439
  existsSync(resolvedArg) &&
@@ -23,6 +23,7 @@
23
23
  import { readFileSync } from "node:fs";
24
24
  import { parseArgs } from "node:util";
25
25
  import * as clack from "@clack/prompts";
26
+ import { toPath } from "../hooks/lib/paths";
26
27
  import { buildGraph, resolveSlug, stats, traverse } from "../tools/knowledge/graph";
27
28
  import {
28
29
  type CompanyInput,
@@ -553,7 +554,7 @@ interface IngestPayload {
553
554
  }
554
555
 
555
556
  async function readIngestInput(file: string | undefined): Promise<string | null> {
556
- if (file) return readFileSync(file, "utf-8");
557
+ if (file) return readFileSync(toPath(file), "utf-8");
557
558
  if (process.stdin.isTTY) return null;
558
559
  return await Bun.stdin.text();
559
560
  }
package/src/cli/skill.ts CHANGED
@@ -8,15 +8,25 @@
8
8
  * pal cli skill doctor --all Evaluate every installed skill, one line each.
9
9
  * pal cli skill author-model Print the flagship model configured to author
10
10
  * skills for the active agent (empty if none).
11
+ * pal cli skill run <skill> <tool> [-- args]
12
+ * Run ~/.pal/skills/<skill>/tools/<tool>, so a
13
+ * SKILL.md can name the tool instead of a path
14
+ * with a tilde no Windows shell expands.
11
15
  */
12
16
 
17
+ import { spawnSync } from "node:child_process";
13
18
  import { existsSync, readdirSync, statSync } from "node:fs";
14
19
  import { resolve } from "node:path";
15
20
  import { getActiveAgent } from "../hooks/lib/agent";
16
21
  import { flagshipAuthorModel } from "../hooks/lib/models";
17
22
  import { palHome } from "../hooks/lib/paths";
18
23
  import { linkPersonalSkill, log } from "../targets/lib";
19
- import { formatReport, formatSummary, lintSkill } from "../tools/lib/skill-doctor";
24
+ import {
25
+ formatReport,
26
+ formatSummary,
27
+ lintSkill,
28
+ resolveSkillDir,
29
+ } from "../tools/lib/skill-doctor";
20
30
 
21
31
  /** Entry names under ~/.pal/skills/, sorted; dangling links included. */
22
32
  function skillEntries(dir: string): string[] {
@@ -59,9 +69,51 @@ function doctorAll(): number {
59
69
  return failing > 0 ? 1 : 0;
60
70
  }
61
71
 
72
+ /** One plain path segment — the containment guarantee for `skill run`. */
73
+ function isPlainSegment(part: string): boolean {
74
+ return (
75
+ part.length > 0 && part !== "." && part !== ".." && !new RegExp(/[/\\\0]/).test(part)
76
+ );
77
+ }
78
+
79
+ /** Playwright tools ship compiled as .mjs and need Node; everything else is Bun. */
80
+ function runtimeFor(file: string): string {
81
+ return file.endsWith(".mjs") ? "node" : "bun";
82
+ }
83
+
84
+ function toolFileName(tool: string): string {
85
+ return new RegExp(/\.[a-z]+$/).test(tool) ? tool : `${tool}.ts`;
86
+ }
87
+
88
+ function runSkillTool(skill: string, tool: string, toolArgs: string[]): number {
89
+ if (!isPlainSegment(skill) || !isPlainSegment(tool)) {
90
+ log.error("Skill and tool must be plain names — no path separators, no '..'");
91
+ return 1;
92
+ }
93
+ const file = toolFileName(tool);
94
+ const path = resolve(palHome(), "skills", skill, "tools", file);
95
+ if (!existsSync(path)) {
96
+ log.error(`No tool '${file}' in skill '${skill}' — looked in ${path}`);
97
+ return 1;
98
+ }
99
+ const { status } = spawnSync(runtimeFor(file), [path, ...toolArgs], {
100
+ stdio: "inherit",
101
+ });
102
+ return status ?? 1;
103
+ }
104
+
62
105
  export async function runSkill(args: string[]): Promise<number> {
63
106
  const [sub, name] = args;
64
107
 
108
+ if (sub === "run") {
109
+ const [, skill, tool, ...rest] = args;
110
+ if (!skill || !tool) {
111
+ log.error("Usage: pal cli skill run <skill> <tool> [-- args]");
112
+ return 1;
113
+ }
114
+ return runSkillTool(skill, tool, rest[0] === "--" ? rest.slice(1) : rest);
115
+ }
116
+
65
117
  if (sub === "author-model") {
66
118
  const model = flagshipAuthorModel(getActiveAgent());
67
119
  if (model) console.log(model);
@@ -71,10 +123,10 @@ export async function runSkill(args: string[]): Promise<number> {
71
123
  if (sub === "doctor") {
72
124
  if (name === "--all") return doctorAll();
73
125
  if (!name) {
74
- log.error("Usage: pal cli skill doctor <name|--all>");
126
+ log.error("Usage: pal cli skill doctor <skill-dir-or-name|--all>");
75
127
  return 1;
76
128
  }
77
- const report = lintSkill(resolve(palHome(), "skills", name));
129
+ const report = lintSkill(resolveSkillDir(name));
78
130
  console.log(formatReport(report));
79
131
  return report.errors > 0 ? 1 : 0;
80
132
  }
@@ -104,6 +156,6 @@ export async function runSkill(args: string[]): Promise<number> {
104
156
  }
105
157
  }
106
158
 
107
- log.error("Usage: pal cli skill <link|doctor|author-model> [name|--all]");
159
+ log.error("Usage: pal cli skill <run|link|doctor|author-model> [name|--all]");
108
160
  return 1;
109
161
  }
@@ -40,7 +40,7 @@ export async function runSubagent(args: string[]): Promise<number> {
40
40
 
41
41
  if (sub === "doctor") {
42
42
  if (!name) {
43
- log.error("Usage: pal cli subagent doctor <name>");
43
+ log.error("Usage: pal cli subagent doctor <file-or-name>");
44
44
  return 1;
45
45
  }
46
46
  const report = lintSubagent(resolveSubagentFile(name));
@@ -9,9 +9,13 @@
9
9
  * Detection reads PAL_AGENT first (set in-process by
10
10
  * `src/targets/opencode/plugin.ts`), then the host's own environment, and only
11
11
  * then the `--agent=` flag the install templates in `assets/templates/*` put
12
- * on the hook command line. See declaredAgent for why that order.
12
+ * on the hook command line. See declaredAgent for why that order. When nothing
13
+ * declares an agent at all, getActiveAgent falls back to the agent CLIs found
14
+ * on PATH rather than assuming claude.
13
15
  */
14
16
 
17
+ import { findBinaryOnPath } from "./which";
18
+
15
19
  export type AgentType = "claude" | "cursor" | "codex" | "copilot" | "opencode" | "vscode";
16
20
 
17
21
  const KNOWN_AGENTS: ReadonlySet<AgentType> = new Set([
@@ -109,9 +113,36 @@ export function declaredAgent(): AgentType | undefined {
109
113
  return agentFromRuntimeEnv() ?? flag;
110
114
  }
111
115
 
112
- /** Which agent's conventions to follow. Assumes "claude" when undeclared. */
116
+ /**
117
+ * The CLI each agent spawns for inference, in the order inference.ts routes
118
+ * them. vscode is absent because it has no CLI of its own — it runs Claude's.
119
+ */
120
+ const AGENT_BINARIES: ReadonlyArray<readonly [AgentType, string]> = [
121
+ ["claude", "claude"],
122
+ ["codex", "codex"],
123
+ ["opencode", "opencode"],
124
+ ["copilot", "copilot"],
125
+ ["cursor", "cursor-agent"],
126
+ ];
127
+
128
+ /**
129
+ * Which agent this machine actually has, for the case where nothing declared
130
+ * one — a plain terminal running `pal cli`, a cron child, a detached spawn.
131
+ * Assuming claude there names an agent that may not be installed, which routes
132
+ * inference to a binary that isn't on PATH and reports a host that isn't there.
133
+ */
134
+ function agentFromInstalledBinary(): AgentType | undefined {
135
+ return AGENT_BINARIES.find(([, binary]) => findBinaryOnPath(binary) !== null)?.[0];
136
+ }
137
+
138
+ /**
139
+ * Which agent's conventions to follow.
140
+ *
141
+ * A declaration always wins; claude remains the last resort so a machine with
142
+ * no agent CLI at all behaves as it always has.
143
+ */
113
144
  export function getActiveAgent(): AgentType {
114
- return declaredAgent() ?? "claude";
145
+ return declaredAgent() ?? agentFromInstalledBinary() ?? "claude";
115
146
  }
116
147
 
117
148
  export const isClaude = () => getActiveAgent() === "claude";
@@ -20,7 +20,7 @@
20
20
  import { copyFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
21
21
  import { homedir } from "node:os";
22
22
  import { resolve } from "node:path";
23
- import { palHome } from "./paths";
23
+ import { palHome, toPath } from "./paths";
24
24
 
25
25
  /** Project name → absolute path on this machine. */
26
26
  export type Bindings = Record<string, string>;
@@ -105,7 +105,7 @@ export function writeBinding(
105
105
  home: string = palHome()
106
106
  ): void {
107
107
  const bindings = readBindings(home);
108
- bindings[project] = resolve(path);
108
+ bindings[project] = toPath(path);
109
109
  writeBindings(bindings, home);
110
110
  }
111
111
 
@@ -4,14 +4,13 @@
4
4
  */
5
5
 
6
6
  import { existsSync, readFileSync } from "node:fs";
7
- import { homedir } from "node:os";
8
7
  import { resolve } from "node:path";
9
8
  import { loadReflectNudge } from "../handlers/reflect-trigger";
10
9
  import { loadAlgorithmReviewNudge } from "./algorithm-review";
11
10
  import { loadAnalyzeNudge } from "./analyze-nudge";
12
11
  import { readLearnings } from "./learning-store";
13
12
  import { loadOpinionContext } from "./opinions";
14
- import { paths } from "./paths";
13
+ import { paths, toPath } from "./paths";
15
14
  import { loadActiveProjectsContext } from "./projects";
16
15
  import { loadRecentNotes } from "./relationship";
17
16
  import { loadFailurePatterns } from "./semi-static";
@@ -24,11 +23,10 @@ function loadStartupFiles(): string {
24
23
  const files = settings.startupFiles();
25
24
  if (files.length === 0) return "";
26
25
 
27
- const home = homedir();
28
26
  const sections: string[] = [];
29
27
 
30
28
  for (const file of files) {
31
- const resolved = file.replace("~", home);
29
+ const resolved = toPath(file);
32
30
  if (!existsSync(resolved)) continue;
33
31
  try {
34
32
  const content = readFileSync(resolved, "utf-8").trim();
@@ -2,13 +2,53 @@ import { existsSync, mkdirSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { resolve } from "node:path";
4
4
 
5
+ /**
6
+ * Turn a path string that came from outside — argv, a flag, an env override, a
7
+ * persisted record — into a real absolute path.
8
+ *
9
+ * "~" is a shell feature, not a filesystem one: bash and zsh expand it before a
10
+ * process starts, cmd.exe has no such feature, and PowerShell hands it to native
11
+ * commands untouched. So node:path has nothing for it (`resolve("~/x")` yields
12
+ * `<cwd>/~/x`) and every caller that skips this function is a Windows bug.
13
+ *
14
+ * Only the leading segment counts. `~user` throws instead of resolving, because
15
+ * it needs a passwd lookup and quietly reading it as a relative directory is the
16
+ * exact failure this replaces.
17
+ */
18
+ export function toPath(input: string, base: string = process.cwd()): string {
19
+ if (input === "~") return homedir();
20
+ if (input.startsWith("~/") || input.startsWith("~\\")) {
21
+ return resolve(homedir(), input.slice(2));
22
+ }
23
+ if (input.startsWith("~")) {
24
+ throw new Error(`Home-relative paths for another user are not supported: ${input}`);
25
+ }
26
+ return resolve(base, input);
27
+ }
28
+
29
+ /**
30
+ * Whether a string names a location rather than a bare identifier. A verb that
31
+ * accepts either `my-skill` or `~/skills/my-skill` has to tell them apart before
32
+ * it looks the argument up in a store — appending a tilde path to a store
33
+ * directory yields a path that can never exist.
34
+ */
35
+ export function namesAPath(input: string): boolean {
36
+ return input.startsWith("~") || input.includes("/") || input.includes("\\");
37
+ }
38
+
39
+ /** An env override names a path the same way a flag does, so it gets the same treatment. */
40
+ function envPath(name: string, fallback: string): string {
41
+ const override = process.env[name];
42
+ return override ? toPath(override) : fallback;
43
+ }
44
+
5
45
  /**
6
46
  * Root of the PAL package (engine code + shipped assets).
7
47
  * In repo mode: the repo root.
8
48
  * In package mode: the global node_modules package directory.
9
49
  */
10
50
  export function palPkg(): string {
11
- return process.env.PAL_PKG || resolve(import.meta.dir, "..", "..", "..");
51
+ return envPath("PAL_PKG", resolve(import.meta.dir, "..", "..", ".."));
12
52
  }
13
53
 
14
54
  /**
@@ -17,7 +57,7 @@ export function palPkg(): string {
17
57
  * Power users who want memory/telos versioned in a repo can override via PAL_HOME.
18
58
  */
19
59
  export function palHome(): string {
20
- return process.env.PAL_HOME || resolve(homedir(), ".pal");
60
+ return envPath("PAL_HOME", resolve(homedir(), ".pal"));
21
61
  }
22
62
 
23
63
  /** Ensure a directory exists, creating it recursively if needed */
@@ -68,12 +108,12 @@ export const paths = {
68
108
  // Platform directories (env override or cross-platform defaults)
69
109
  const h = homedir();
70
110
  export const platform = {
71
- claudeDir: () => process.env.PAL_CLAUDE_DIR || resolve(h, ".claude"),
72
- opencodeDir: () => process.env.PAL_OPENCODE_DIR || resolve(h, ".config", "opencode"),
73
- cursorDir: () => process.env.PAL_CURSOR_DIR || resolve(h, ".cursor"),
74
- copilotDir: () => process.env.PAL_COPILOT_DIR || resolve(h, ".copilot"),
75
- codexDir: () => process.env.PAL_CODEX_DIR || resolve(h, ".codex"),
76
- agentsDir: () => process.env.PAL_AGENTS_DIR || resolve(h, ".agents"),
111
+ claudeDir: () => envPath("PAL_CLAUDE_DIR", resolve(h, ".claude")),
112
+ opencodeDir: () => envPath("PAL_OPENCODE_DIR", resolve(h, ".config", "opencode")),
113
+ cursorDir: () => envPath("PAL_CURSOR_DIR", resolve(h, ".cursor")),
114
+ copilotDir: () => envPath("PAL_COPILOT_DIR", resolve(h, ".copilot")),
115
+ codexDir: () => envPath("PAL_CODEX_DIR", resolve(h, ".codex")),
116
+ agentsDir: () => envPath("PAL_AGENTS_DIR", resolve(h, ".agents")),
77
117
  } as const;
78
118
 
79
119
  // Engine/asset paths (in PAL_PKG / repo root)
@@ -459,7 +459,7 @@ export function loadActiveProjectsContext(cwd: string = process.cwd()): string {
459
459
  if (showHint) {
460
460
  if (visible.length > 0) lines.push("");
461
461
  lines.push(
462
- `💡 \`${projectRoot}\` looks like a project but isn't registered. If substantive work starts here, suggest registering it via \`bun ~/.pal/tools/project.ts create\`.`
462
+ `💡 \`${projectRoot}\` looks like a project but isn't registered. If substantive work starts here, suggest registering it via \`pal cli project create\`.`
463
463
  );
464
464
  }
465
465
 
@@ -9,9 +9,26 @@
9
9
  * names — passing the full `.cmd`/`.exe` path bypasses that fragility.
10
10
  */
11
11
 
12
- import { accessSync, constants, existsSync } from "node:fs";
12
+ import { accessSync, constants, statSync } from "node:fs";
13
13
  import { delimiter, resolve as resolvePath } from "node:path";
14
14
 
15
+ /**
16
+ * A directory carries the execute bit as "traversable", so X_OK alone accepts
17
+ * a folder that happens to share a CLI's name. statSync follows symlinks, so a
18
+ * linked binary still resolves. Windows has no executable bit — being a file
19
+ * under a PATHEXT extension is all it can offer.
20
+ */
21
+ function isExecutableFile(candidate: string): boolean {
22
+ try {
23
+ if (!statSync(candidate).isFile()) return false;
24
+ if (process.platform === "win32") return true;
25
+ accessSync(candidate, constants.X_OK);
26
+ return true;
27
+ } catch {
28
+ return false;
29
+ }
30
+ }
31
+
15
32
  /** Resolve a binary on PATH to its full absolute path, or null if absent. */
16
33
  export function findBinaryOnPath(name: string): string | null {
17
34
  const PATH = process.env.PATH;
@@ -24,17 +41,7 @@ export function findBinaryOnPath(name: string): string | null {
24
41
  if (!dir) continue;
25
42
  for (const ext of exts) {
26
43
  const candidate = resolvePath(dir, name + ext);
27
- try {
28
- if (process.platform === "win32") {
29
- // Windows has no executable bit — existence in PATHEXT is enough.
30
- if (existsSync(candidate)) return candidate;
31
- } else {
32
- accessSync(candidate, constants.X_OK);
33
- return candidate;
34
- }
35
- } catch {
36
- /* not here — try next */
37
- }
44
+ if (isExecutableFile(candidate)) return candidate;
38
45
  }
39
46
  }
40
47
  return null;
@@ -18,6 +18,7 @@ import { parseArgs } from "node:util";
18
18
  import { paths } from "../../hooks/lib/paths";
19
19
  import { buildReflection, intOr, reflectionLine } from "../lib/algorithm-reflect";
20
20
  import { emit } from "../lib/emit";
21
+ import { scriptArgs } from "../lib/script-args";
21
22
 
22
23
  const HELP = `
23
24
  AlgorithmReflect — Log algorithm performance after LEARN phase
@@ -45,9 +46,9 @@ function reflectionsPath(): string {
45
46
  return paths.reflectionsFile();
46
47
  }
47
48
 
48
- function run() {
49
+ export function run(argv: string[] = scriptArgs()) {
49
50
  const { values } = parseArgs({
50
- args: Bun.argv.slice(2),
51
+ args: argv,
51
52
  options: {
52
53
  task: { type: "string" },
53
54
  criteria: { type: "string" },
@@ -20,6 +20,7 @@
20
20
  import { parseArgs } from "node:util";
21
21
  import { readReflections } from "../../hooks/lib/learning-store";
22
22
  import { paths } from "../../hooks/lib/paths";
23
+ import { scriptArgs } from "../lib/script-args";
23
24
 
24
25
  /** Algorithm areas a Q2 idea can target, mapped to ALGORITHM.md structure. */
25
26
  const BUCKETS: { key: string; label: string; patterns: RegExp }[] = [
@@ -202,9 +203,9 @@ export function formatAlgorithmReport(s: AlgorithmSynthesis): string {
202
203
  return lines.join("\n");
203
204
  }
204
205
 
205
- if (import.meta.main) {
206
+ export function run(argv: string[] = scriptArgs()) {
206
207
  const { values } = parseArgs({
207
- args: process.argv.slice(2),
208
+ args: argv,
208
209
  options: {
209
210
  since: { type: "string" },
210
211
  json: { type: "boolean", default: false },
@@ -216,3 +217,5 @@ if (import.meta.main) {
216
217
  values.json ? JSON.stringify(result, null, 2) : formatAlgorithmReport(result)
217
218
  );
218
219
  }
220
+
221
+ if (import.meta.main) run();
@@ -14,6 +14,7 @@ import { parseArgs } from "node:util";
14
14
  import { writeLastAnalyzeDate } from "../../hooks/lib/analyze-nudge";
15
15
  import { analyze } from "../../hooks/lib/graduation";
16
16
  import { reportLines } from "../lib/analyze-report";
17
+ import { scriptArgs } from "../lib/script-args";
17
18
 
18
19
  const HELP = `
19
20
  PAL Learning Analysis — unified graduation + ratings report
@@ -36,7 +37,7 @@ const HELP = `
36
37
  Usage: pal cli analyze [--actionable]
37
38
  `;
38
39
 
39
- export async function run(argv: string[] = Bun.argv.slice(2)) {
40
+ export async function run(argv: string[] = scriptArgs()) {
40
41
  const { values } = parseArgs({
41
42
  args: argv,
42
43
  options: {