portable-agent-layer 0.71.0 → 0.73.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 (104) hide show
  1. package/package.json +10 -7
  2. package/src/cli/migrate.ts +1 -1
  3. package/src/cli/server.ts +7 -0
  4. package/src/cli/skill.ts +1 -1
  5. package/src/hooks/CompactRecover.ts +28 -86
  6. package/src/hooks/LedgerUnapplied.ts +3 -28
  7. package/src/hooks/LoadContext.ts +33 -60
  8. package/src/hooks/SecurityValidator.ts +16 -109
  9. package/src/hooks/handlers/agenda.ts +195 -7
  10. package/src/hooks/handlers/failure-principle.ts +19 -44
  11. package/src/hooks/handlers/session-intelligence.ts +13 -70
  12. package/src/hooks/lib/agenda-store.ts +2 -0
  13. package/src/hooks/lib/capture-store.ts +103 -0
  14. package/src/hooks/lib/compact-recall.ts +89 -0
  15. package/src/hooks/lib/failure-principle.ts +98 -0
  16. package/src/hooks/lib/goal-links.ts +83 -0
  17. package/src/hooks/lib/ledger-hook.ts +35 -0
  18. package/src/hooks/lib/ledger.ts +48 -1
  19. package/src/hooks/lib/models.ts +1 -1
  20. package/src/hooks/lib/projects.ts +5 -0
  21. package/src/hooks/lib/security-gate.ts +159 -0
  22. package/src/hooks/lib/session-context.ts +74 -0
  23. package/src/hooks/lib/settings.ts +2 -0
  24. package/src/hooks/lib/telos-goals.ts +8 -2
  25. package/src/hooks/lib/token-usage.ts +2 -0
  26. package/src/tools/agent/algorithm-reflect.ts +28 -97
  27. package/src/tools/agent/analyze.ts +19 -120
  28. package/src/tools/agent/handoff-note.ts +29 -77
  29. package/src/tools/agent/project.ts +34 -153
  30. package/src/tools/agent/relationship-note.ts +27 -46
  31. package/src/tools/agent/synthesize.ts +1 -1
  32. package/src/tools/agent/thread.ts +43 -123
  33. package/src/tools/control-room/attention.ts +146 -0
  34. package/src/tools/control-room/data.ts +78 -7
  35. package/src/tools/control-room/detail.ts +158 -0
  36. package/src/tools/control-room/matrix.ts +92 -20
  37. package/src/tools/control-room/prefs.ts +96 -0
  38. package/src/tools/control-room/server-config.ts +8 -0
  39. package/src/tools/control-room/server.ts +196 -11
  40. package/src/tools/control-room/snooze.ts +65 -0
  41. package/src/tools/control-room/static.ts +68 -0
  42. package/src/tools/control-room/ui/app.tsx +196 -50
  43. package/src/tools/control-room/ui/attention-bell.tsx +118 -0
  44. package/src/tools/control-room/ui/components/README.md +18 -0
  45. package/src/tools/control-room/ui/components/badge.tsx +38 -0
  46. package/src/tools/control-room/ui/components/button.tsx +43 -0
  47. package/src/tools/control-room/ui/components/card.tsx +45 -0
  48. package/src/tools/control-room/ui/components/input.tsx +24 -0
  49. package/src/tools/control-room/ui/components/label.tsx +18 -0
  50. package/src/tools/control-room/ui/components/popover.tsx +37 -0
  51. package/src/tools/control-room/ui/components/separator.tsx +25 -0
  52. package/src/tools/control-room/ui/components/skeleton.tsx +14 -0
  53. package/src/tools/control-room/ui/components/switch.tsx +27 -0
  54. package/src/tools/control-room/ui/components/table.tsx +60 -0
  55. package/src/tools/control-room/ui/components/toggle-group.tsx +38 -0
  56. package/src/tools/control-room/ui/dist/assets/index-BoQjFpzV.js +49 -0
  57. package/src/tools/control-room/ui/dist/assets/index-Dncp2bYg.css +2 -0
  58. package/src/tools/control-room/ui/dist/index.html +19 -0
  59. package/src/tools/control-room/ui/format.ts +0 -12
  60. package/src/tools/control-room/ui/frame.tsx +125 -0
  61. package/src/tools/control-room/ui/index.html +2 -2
  62. package/src/tools/control-room/ui/lib/api.ts +27 -0
  63. package/src/tools/control-room/ui/lib/cn.ts +6 -0
  64. package/src/tools/control-room/ui/lib/write.ts +43 -0
  65. package/src/tools/control-room/ui/package.json +28 -0
  66. package/src/tools/control-room/ui/parts.tsx +235 -0
  67. package/src/tools/control-room/ui/screens/log.tsx +274 -0
  68. package/src/tools/control-room/ui/screens/project-detail.tsx +302 -0
  69. package/src/tools/control-room/ui/screens/projects.tsx +128 -0
  70. package/src/tools/control-room/ui/screens/settings.tsx +205 -0
  71. package/src/tools/control-room/ui/screens/today.tsx +391 -0
  72. package/src/tools/control-room/ui/theme.css +127 -0
  73. package/src/tools/control-room/ui/vite.config.ts +36 -0
  74. package/src/tools/control-room/writes.ts +106 -0
  75. package/src/tools/ledger/outcomes.ts +9 -0
  76. package/src/tools/ledger/query.ts +2 -0
  77. package/src/tools/ledger/view.ts +34 -10
  78. package/src/tools/lib/algorithm-reflect.ts +84 -0
  79. package/src/tools/lib/analyze-report.ts +120 -0
  80. package/src/tools/lib/handoff-note.ts +102 -0
  81. package/src/tools/lib/note-flags.ts +59 -0
  82. package/src/tools/lib/project-isc.ts +212 -0
  83. package/src/tools/lib/relationship-reflect.ts +402 -0
  84. package/src/tools/lib/self-model.ts +499 -0
  85. package/src/tools/lib/session-usage.ts +216 -0
  86. package/src/tools/lib/skill-doctor.ts +457 -0
  87. package/src/tools/lib/thread.ts +119 -0
  88. package/src/tools/lib/token-report.ts +173 -0
  89. package/src/tools/lib/transcript-usage.ts +42 -0
  90. package/src/tools/lib/usage-buckets.ts +329 -0
  91. package/src/tools/relationship-reflect.ts +48 -412
  92. package/src/tools/self-model.ts +76 -558
  93. package/src/tools/session-summary.ts +8 -215
  94. package/src/tools/skill-doctor.ts +9 -444
  95. package/src/tools/token-cost.ts +18 -428
  96. package/src/tools/control-room/ui/agenda.tsx +0 -43
  97. package/src/tools/control-room/ui/agents.tsx +0 -67
  98. package/src/tools/control-room/ui/app.css +0 -857
  99. package/src/tools/control-room/ui/board.tsx +0 -82
  100. package/src/tools/control-room/ui/handoffs.tsx +0 -37
  101. package/src/tools/control-room/ui/ledger.tsx +0 -136
  102. package/src/tools/control-room/ui/matrix.tsx +0 -117
  103. package/src/tools/control-room/ui/panel.tsx +0 -60
  104. package/src/tools/control-room/ui/signal.tsx +0 -161
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portable-agent-layer",
3
- "version": "0.71.0",
3
+ "version": "0.73.0",
4
4
  "description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,6 +13,9 @@
13
13
  "README.md",
14
14
  "LICENSE"
15
15
  ],
16
+ "workspaces": [
17
+ "src/tools/control-room/ui"
18
+ ],
16
19
  "engines": {
17
20
  "bun": ">=1.4.0"
18
21
  },
@@ -53,7 +56,9 @@
53
56
  "lint-staged": "lint-staged",
54
57
  "prepare": "bun .husky/install.mjs",
55
58
  "build:skill-tools": "bun run scripts/build-skill-tools.ts",
56
- "prepack": "bun run build:skill-tools",
59
+ "build:ui": "bun run --filter @pal/control-room-ui build",
60
+ "dev:ui": "bun run --filter @pal/control-room-ui dev",
61
+ "prepack": "bun run build:skill-tools && bun run build:ui",
57
62
  "install:all": "bun run src/cli/index.ts cli install",
58
63
  "uninstall": "bun run src/cli/index.ts cli uninstall",
59
64
  "tool:synthesize": "bun run src/tools/agent/synthesize.ts",
@@ -61,7 +66,9 @@
61
66
  "tool:wisdom-frame": "bun run src/tools/agent/wisdom-frame.ts",
62
67
  "tool:reflect": "bun run src/tools/relationship-reflect.ts",
63
68
  "eval:sentiment": "bun eval/run.ts sentiment",
64
- "eval:failure-principle": "bun eval/run.ts failure-principle"
69
+ "eval:failure-principle": "bun eval/run.ts failure-principle",
70
+ "eval:goal-links": "bun eval/run.ts goal-links",
71
+ "eval:waiting-on": "bun eval/run.ts waiting-on"
65
72
  },
66
73
  "lint-staged": {
67
74
  "*.ts": [
@@ -83,8 +90,6 @@
83
90
  "@types/adm-zip": "^0.5.8",
84
91
  "@types/bun": "latest",
85
92
  "@types/node": "latest",
86
- "@types/react": "^19.2.18",
87
- "@types/react-dom": "^19.2.7",
88
93
  "husky": "^9.1.7",
89
94
  "jscpd": "^4.2.3",
90
95
  "knip": "^6.14.1",
@@ -102,8 +107,6 @@
102
107
  "marked": "18.0.4",
103
108
  "pdf-lib": "1.17.1",
104
109
  "playwright": "^1.60.0",
105
- "react": "^19.2.8",
106
- "react-dom": "^19.2.8",
107
110
  "unpdf": "^0.12.0"
108
111
  }
109
112
  }
@@ -26,7 +26,6 @@ import {
26
26
  readProject,
27
27
  writeProject,
28
28
  } from "../hooks/lib/projects";
29
- import { readThreads, type Thread, writeThreads } from "../tools/agent/thread";
30
29
  import { appendSourceLog } from "../tools/knowledge/ingest";
31
30
  import {
32
31
  type Entity,
@@ -35,6 +34,7 @@ import {
35
34
  save as knowledgeSave,
36
35
  slugify,
37
36
  } from "../tools/knowledge/lib";
37
+ import { readThreads, type Thread, writeThreads } from "../tools/lib/thread";
38
38
 
39
39
  // ── Types ─────────────────────────────────────────────────────────
40
40
 
package/src/cli/server.ts CHANGED
@@ -12,6 +12,7 @@ import { parseArgs } from "node:util";
12
12
  import { spawnDetachedInference } from "../hooks/lib/detached-inference";
13
13
  import { paths } from "../hooks/lib/paths";
14
14
  import { DEFAULT_PORT, LOOPBACK, type ServerStatus } from "../tools/control-room/server";
15
+ import { BUILD_COMMAND, buildPage, isBuilt } from "../tools/control-room/static";
15
16
 
16
17
  interface ServerState {
17
18
  pid: number;
@@ -137,6 +138,12 @@ async function cmdStart(args: string[]): Promise<number> {
137
138
  return 0;
138
139
  }
139
140
 
141
+ if (!isBuilt() && !buildPage()) {
142
+ return fail(
143
+ `The control room's page is not built and could not be. Run: ${BUILD_COMMAND}`
144
+ );
145
+ }
146
+
140
147
  spawnDetachedInference(SERVER_SCRIPT, [`--port=${port}`], "control-room");
141
148
  const status = await waitUntilAnswering(port);
142
149
  if (!status)
package/src/cli/skill.ts CHANGED
@@ -16,7 +16,7 @@ import { getActiveAgent } from "../hooks/lib/agent";
16
16
  import { flagshipAuthorModel } from "../hooks/lib/models";
17
17
  import { palHome } from "../hooks/lib/paths";
18
18
  import { linkPersonalSkill, log } from "../targets/lib";
19
- import { formatReport, formatSummary, lintSkill } from "../tools/skill-doctor";
19
+ import { formatReport, formatSummary, lintSkill } from "../tools/lib/skill-doctor";
20
20
 
21
21
  /** Entry names under ~/.pal/skills/, sorted; dangling links included. */
22
22
  function skillEntries(dir: string): string[] {
@@ -1,19 +1,20 @@
1
1
  /**
2
- * Hook: SessionStart with matcher "compact" — Re-injects the last exchange after compaction.
2
+ * Hook: SessionStart(compact) — re-injects the exchange that was in flight when
3
+ * the window filled, since the summary may have collapsed it.
3
4
  *
4
- * Reads the state file written by PreCompactPersist.ts and prints a system-reminder to
5
- * stdout. SessionStart stdout is added as context that Claude can see, per Claude Code
6
- * hook docs.
7
- *
8
- * Storage: ~/.pal/memory/state/last-exchange/{session_id}.json (with latest.json fallback).
5
+ * What to inject and what may then be deleted is in lib/compact-recall.ts, where
6
+ * a test can import it.
9
7
  */
10
8
 
11
- import { existsSync } from "node:fs";
12
9
  import { readFile, unlink } from "node:fs/promises";
13
- import { resolve } from "node:path";
14
10
  import { isCursor } from "./lib/agent";
11
+ import {
12
+ buildRecall,
13
+ findSavedExchange,
14
+ isConsumable,
15
+ type SavedExchange,
16
+ } from "./lib/compact-recall";
15
17
  import { logDebug, logError } from "./lib/log";
16
- import { paths } from "./lib/paths";
17
18
  import { isPalSpawnedInference } from "./lib/spawn-guard";
18
19
  import { readStdinJSON } from "./lib/stdin";
19
20
 
@@ -26,90 +27,31 @@ interface SessionStartInput {
26
27
  source?: "startup" | "resume" | "clear" | "compact" | string;
27
28
  }
28
29
 
29
- interface SavedExchange {
30
- sessionId: string;
31
- timestamp: string;
32
- trigger: string | null;
33
- customInstructions: string | null;
34
- userMessage: string;
35
- assistantMessage: string;
36
- }
37
-
38
- // Hook output cap is 10,000 chars per Claude Code docs; leave headroom for framing.
39
- const MAX_OUTPUT = 9_000;
40
-
41
- function truncate(s: string, max: number): string {
42
- if (s.length <= max) return s;
43
- return `${s.slice(0, max)}\n[... truncated ${s.length - max} chars]`;
44
- }
45
-
46
- const main = async () => {
30
+ try {
47
31
  const input = await readStdinJSON<SessionStartInput>();
48
32
 
49
- // The matcher gates this hook to "compact" sessions, but verify defensively in case
50
- // the matcher is misconfigured or the hook is invoked manually.
33
+ // The matcher gates this hook to "compact" sessions, but verify defensively in
34
+ // case the matcher is misconfigured or the hook is invoked manually.
51
35
  if (input?.source && input.source !== "compact") {
52
36
  logDebug("CompactRecover", `source=${input.source} — not compact, skipping`);
53
37
  process.exit(0);
54
38
  }
55
39
 
56
- try {
57
- const stateDir = resolve(paths.state(), "last-exchange");
58
- const sessionId = input?.session_id;
59
- const candidates = [
60
- sessionId ? resolve(stateDir, `${sessionId}.json`) : null,
61
- resolve(stateDir, "latest.json"),
62
- ].filter((p): p is string => p !== null);
63
-
64
- const file = candidates.find((p) => existsSync(p));
65
- if (!file) {
66
- logDebug("CompactRecover", "No saved exchange found — silent no-op");
67
- process.exit(0);
68
- }
69
-
70
- const saved = JSON.parse(await readFile(file, "utf-8")) as SavedExchange;
71
- const userBudget = Math.floor(MAX_OUTPUT * 0.4);
72
- const assistantBudget = MAX_OUTPUT - userBudget - 300; // reserve for framing
73
-
74
- const out = [
75
- "<system-reminder>",
76
- "## Last exchange before compaction",
77
- "_Restored verbatim from PAL state. The compaction summary may have collapsed this; the originals are below._",
78
- "",
79
- "**User:**",
80
- truncate(saved.userMessage || "(no user message captured)", userBudget),
81
- "",
82
- "**Assistant:**",
83
- truncate(
84
- saved.assistantMessage || "(no assistant message captured)",
85
- assistantBudget
86
- ),
87
- "</system-reminder>",
88
- ].join("\n");
89
-
90
- if (isCursor()) {
91
- process.stdout.write(JSON.stringify({ additional_context: out }));
92
- } else {
93
- process.stdout.write(out);
94
- }
95
- logDebug("CompactRecover", `Re-injected ${out.length} chars from ${file}`);
96
-
97
- // Consume-on-read: drop the session-keyed file after a successful injection so it
98
- // doesn't sit on disk forever. latest.json is preserved as a safety fallback and
99
- // gets overwritten on the next compaction.
100
- const sessionFile = sessionId ? resolve(stateDir, `${sessionId}.json`) : null;
101
- if (sessionFile && file === sessionFile) {
102
- try {
103
- await unlink(sessionFile);
104
- } catch (err) {
105
- logError("CompactRecover:cleanup", err);
106
- }
107
- }
108
- } catch (err) {
109
- logError("CompactRecover", err);
40
+ const sessionId = input?.session_id;
41
+ const file = findSavedExchange(sessionId);
42
+ if (!file) {
43
+ logDebug("CompactRecover", "No saved exchange found — silent no-op");
44
+ process.exit(0);
110
45
  }
111
46
 
112
- process.exit(0);
113
- };
47
+ const saved = JSON.parse(await readFile(file, "utf-8")) as SavedExchange;
48
+ const out = buildRecall(saved);
49
+ process.stdout.write(isCursor() ? JSON.stringify({ additional_context: out }) : out);
50
+ logDebug("CompactRecover", `Re-injected ${out.length} chars from ${file}`);
51
+
52
+ if (isConsumable(file, sessionId)) await unlink(file);
53
+ } catch (err) {
54
+ logError("CompactRecover", err);
55
+ }
114
56
 
115
- await main();
57
+ process.exit(0);
@@ -20,27 +20,11 @@
20
20
  * Silent and fail-open, for the same reason as the other halves.
21
21
  */
22
22
 
23
- import { existsSync, readFileSync } from "node:fs";
24
- import {
25
- claimPending,
26
- type PendingSnapshot,
27
- reapStalePending,
28
- recordAction,
29
- } from "./lib/ledger";
30
- import { ledgeredCalls, unappliedVerdictOf } from "./lib/ledger-hook";
23
+ import { reapStalePending } from "./lib/ledger";
24
+ import { commitUnapplied, ledgeredCalls, unappliedVerdictOf } from "./lib/ledger-hook";
31
25
  import { logDebug } from "./lib/log";
32
26
  import { readStdinJSON } from "./lib/stdin";
33
27
 
34
- /**
35
- * The snapshot is the trustworthy source, but its absence is recoverable here
36
- * in a way it never is after a successful edit: nothing landed, so whatever is
37
- * on disk now is still the before-state.
38
- */
39
- function beforeState(pending: PendingSnapshot | null, target: string): string | null {
40
- if (pending) return pending.before;
41
- return existsSync(target) ? readFileSync(target, "utf-8") : null;
42
- }
43
-
44
28
  try {
45
29
  const input = await readStdinJSON<Record<string, unknown>>();
46
30
  if (!input) process.exit(0);
@@ -50,16 +34,7 @@ try {
50
34
  if (!verdict) process.exit(0);
51
35
 
52
36
  for (const call of calls) {
53
- const entry = recordAction({
54
- tool: call.tool,
55
- target: call.target,
56
- outcome: verdict.outcome,
57
- before: beforeState(claimPending(call.toolUseId), call.target),
58
- // Nothing landed. That is what this event means, and it is the difference
59
- // between this entry and an applied one.
60
- after: null,
61
- reason: verdict.reason,
62
- });
37
+ const entry = commitUnapplied(call, verdict);
63
38
  logDebug("LedgerUnapplied", `recorded ${entry.id} ${entry.outcome} ${entry.target}`);
64
39
  }
65
40
 
@@ -1,97 +1,70 @@
1
1
  /**
2
2
  * Hook: SessionStart — Injects dynamic context + regenerates AGENTS.md if stale.
3
3
  *
4
- * Static context (TELOS, setup prompt) is loaded natively from AGENTS.md / CLAUDE.md.
5
- * This hook injects dynamic context only: wisdom principles, relationship notes,
6
- * learning digest, signal trends, failure patterns, active work state.
4
+ * Static context (TELOS, setup prompt) is loaded natively from AGENTS.md /
5
+ * CLAUDE.md. This hook injects dynamic context only: wisdom principles,
6
+ * relationship notes, learning digest, signal trends, failure patterns, work state.
7
7
  *
8
- * Copilot: the CLI reads additionalContext from this hook's stdout, while
9
- * ~/.copilot/instructions/ is read only by the VS Code extension. The merged
10
- * context goes to both so either surface picks it up.
8
+ * Which agent gets what, in which envelope, is in lib/session-context.ts.
11
9
  */
12
10
 
13
11
  import { mkdirSync, writeFileSync } from "node:fs";
14
12
  import { resolve } from "node:path";
15
- import { getActiveAgent, isCodex, isCopilot, isCursor } from "./lib/agent";
13
+ import { getActiveAgent } from "./lib/agent";
16
14
  import { buildClaudeMd, regenerateIfNeeded } from "./lib/claude-md";
17
15
  import { type AgentTarget, buildSystemReminder } from "./lib/context";
18
16
  import { logContextSnapshot, logDebug, logError } from "./lib/log";
19
17
  import { platform } from "./lib/paths";
18
+ import {
19
+ contextEnvelope,
20
+ copilotInstructions,
21
+ isSubagentSession,
22
+ needsAgentsMd,
23
+ } from "./lib/session-context";
20
24
  import { isPalSpawnedInference } from "./lib/spawn-guard";
21
25
 
22
26
  // Recursion guard — when this process is a PAL-spawned inference subprocess,
23
27
  // skip all context loading so we don't trigger another inference call.
24
28
  if (isPalSpawnedInference()) process.exit(0);
25
29
 
26
- // --- Skip heavy context for subagents ---
27
- const isSubagent =
28
- process.env.CLAUDE_PROJECT_DIR?.includes("/.claude/Agents/") ||
29
- process.env.CLAUDE_AGENT_TYPE !== undefined;
30
-
31
- if (isSubagent) {
30
+ if (isSubagentSession(process.env)) {
32
31
  logDebug("LoadContext", "Subagent session — skipping context loading");
33
32
  process.exit(0);
34
33
  }
35
34
 
36
- // --- Regenerate CLAUDE.md if telos or setup changed ---
37
35
  try {
38
- const rebuilt = regenerateIfNeeded();
39
- if (rebuilt) logDebug("LoadContext", "AGENTS.md regenerated");
36
+ if (regenerateIfNeeded()) logDebug("LoadContext", "AGENTS.md regenerated");
40
37
  } catch (err) {
41
38
  logError("LoadContext:regenerate", err);
42
39
  }
43
40
 
44
- // --- Context to stdout (or file for Copilot) ---
45
41
  try {
46
- // Determine agent target — controls which sections are skipped (loaded natively instead).
47
42
  const active = getActiveAgent();
48
- const agent: AgentTarget =
43
+ // The reminder is built for one of three targets; every other agent reads the
44
+ // same shape Claude Code does.
45
+ const target: AgentTarget =
49
46
  active === "copilot" || active === "cursor" ? active : "claude";
50
- const reminder = buildSystemReminder({ agent });
47
+ const reminder = buildSystemReminder({ agent: target });
51
48
  if (!reminder) process.exit(0);
52
49
  logContextSnapshot(reminder);
53
50
 
54
- if (isCopilot()) {
55
- // Copilot: semi-static in ~/.copilot/instructions/pal-*.instructions.md (written at stop).
56
- // Write AGENTS.md + dynamic context to pal-session.instructions.md on each session start.
57
- const instructionsDir = resolve(platform.copilotDir(), "instructions");
58
- mkdirSync(instructionsDir, { recursive: true });
59
- const agentsMd = buildClaudeMd();
60
- const context = [agentsMd, reminder].filter(Boolean).join("\n\n");
61
- if (context) {
62
- writeFileSync(
63
- resolve(instructionsDir, "pal-session.instructions.md"),
64
- `---\napplyTo: "**"\n---\n\n${context}`,
65
- "utf-8"
66
- );
67
- process.stdout.write(JSON.stringify({ additionalContext: context }));
68
- }
69
- logDebug(
70
- "LoadContext",
71
- `Copilot session instructions written: ${context.length} chars`
72
- );
73
- } else if (isCursor()) {
74
- // Cursor: semi-static in ~/.cursor/rules/pal-context.mdc; inject AGENTS.md + dynamic here
75
- const agentsMd = buildClaudeMd();
76
- const context = [agentsMd, reminder].filter(Boolean).join("\n\n");
77
- process.stdout.write(JSON.stringify({ additional_context: context }));
78
- logDebug("LoadContext", `Reminder injected: ${reminder.length} chars`);
79
- } else if (isCodex()) {
80
- // Codex: AGENTS.md already loaded via symlink; inject only dynamic context
81
- process.stdout.write(
82
- JSON.stringify({
83
- hookSpecificOutput: {
84
- hookEventName: "SessionStart",
85
- additionalContext: reminder,
86
- },
87
- })
88
- );
89
- logDebug("LoadContext", `Codex reminder injected: ${reminder.length} chars`);
90
- } else {
91
- // Claude Code (and opencode, which uses the plugin path not this hook): raw text
92
- console.log(reminder);
93
- logDebug("LoadContext", `Reminder injected: ${reminder.length} chars`);
51
+ const envelope = contextEnvelope(
52
+ active,
53
+ reminder,
54
+ needsAgentsMd(active) ? buildClaudeMd() : ""
55
+ );
56
+ if (!envelope) process.exit(0);
57
+
58
+ if (envelope.file) {
59
+ const dir = resolve(platform.copilotDir(), "instructions");
60
+ mkdirSync(dir, { recursive: true });
61
+ const path = resolve(dir, "pal-session.instructions.md");
62
+ writeFileSync(path, copilotInstructions(envelope.file), "utf-8");
94
63
  }
64
+
65
+ if (envelope.kind === "text") console.log(envelope.payload);
66
+ else process.stdout.write(envelope.payload);
67
+ logDebug("LoadContext", `Reminder injected: ${reminder.length} chars`);
95
68
  } catch (err) {
96
69
  logError("LoadContext:reminder", err);
97
70
  }
@@ -3,125 +3,32 @@
3
3
  * Emits the current agent's deny response to block, or exits silently to allow.
4
4
  *
5
5
  * Fail-open design: if anything goes wrong, the command is allowed through.
6
+ *
7
+ * The decision itself is in lib/security-gate.ts, where a test can import it.
6
8
  */
7
9
 
8
- import { blockResponse, normalizeToolUse } from "./lib/agent";
9
- import { logDebug } from "./lib/log";
10
- import { checkBashCommand, checkFilePath } from "./lib/security";
10
+ import { blockResponse } from "./lib/agent";
11
+ import { recordBlocked } from "./lib/ledger";
12
+ import { logError } from "./lib/log";
13
+ import { decideRefusal, type SecurityInput } from "./lib/security-gate";
11
14
  import { readStdinJSON } from "./lib/stdin";
12
15
 
13
- // beforeShellExecution shape (Cursor only) — flat, no tool-name wrapper
14
- interface ShellExecInput {
15
- command: string;
16
- sandbox?: boolean;
17
- }
18
-
19
- type SecurityInput = Record<string, unknown> | ShellExecInput;
20
-
21
- function isShellExec(input: SecurityInput): input is ShellExecInput {
22
- return !("tool_name" in input) && !("toolName" in input) && "command" in input;
23
- }
24
-
25
- // A name this list misses is a command this hook waves through, so both sets mirror
26
- // the tool names VS Code's own Copilot build ships in its shell and edit tool sets.
27
- const SHELL_TOOLS = [
28
- "bash",
29
- "shell",
30
- "powershell",
31
- "local_shell",
32
- "runinterminal",
33
- "run_in_terminal",
34
- "terminal",
35
- "execute_command",
36
- ];
37
-
38
- const FILE_WRITE_TOOLS = [
39
- "write",
40
- "edit",
41
- "multiedit",
42
- "write_file",
43
- "apply_patch",
44
- "applypatch",
45
- "create",
46
- "create_file",
47
- "createfile",
48
- "str_replace",
49
- "str_replace_editor",
50
- "insert",
51
- "insert_edit_into_file",
52
- "replace_string_in_file",
53
- "multi_replace_string_in_file",
54
- "replacestring",
55
- "edit_notebook_file",
56
- "notebookedit",
57
- ];
58
-
59
- /** First of `keys` present as a non-empty string — agents disagree on argument spelling. */
60
- function firstStringArg(
61
- args: Record<string, unknown>,
62
- keys: string[]
63
- ): string | undefined {
64
- for (const key of keys) {
65
- const value = args[key];
66
- if (typeof value === "string" && value.length > 0) return value;
67
- }
68
- return undefined;
69
- }
70
-
71
- /** Tool names that run a shell command, across every agent's naming. */
72
- function runsShellCommand(toolName: string): boolean {
73
- return SHELL_TOOLS.includes(toolName.toLowerCase());
74
- }
75
-
76
- /** Tool names that write to a file, across every agent's naming. */
77
- function writesFile(toolName: string): boolean {
78
- return FILE_WRITE_TOOLS.includes(toolName.toLowerCase());
79
- }
80
-
81
16
  try {
82
17
  const input = await readStdinJSON<SecurityInput>();
83
18
  if (!input) process.exit(0);
84
19
 
85
- if (isShellExec(input)) {
86
- // beforeShellExecution — command is always a shell command
87
- const reason = checkBashCommand(input.command);
88
- if (reason) {
89
- process.stdout.write(blockResponse(`Blocked: ${reason}`));
90
- }
91
- process.exit(0);
92
- }
93
-
94
- const toolUse = normalizeToolUse(input);
95
- if (!toolUse) process.exit(0);
20
+ const refusal = decideRefusal(input, process.cwd());
21
+ if (!refusal) process.exit(0);
96
22
 
97
- // Each agent names its shell/write tools differently; log the real name so an
98
- // unrecognized one shows up here instead of silently skipping the check.
99
- logDebug(
100
- "SecurityValidator",
101
- `toolName=${toolUse.toolName} args=${Object.keys(toolUse.toolInput).join(",")}`
102
- );
103
-
104
- const command = firstStringArg(toolUse.toolInput, ["command", "commandLine", "script"]);
105
- if (runsShellCommand(toolUse.toolName) && typeof command === "string") {
106
- const reason = checkBashCommand(command);
107
- const verdict = reason ? `BLOCK(${reason})` : "ALLOW";
108
- // "No output" from a downstream tool is indistinguishable between "denied,
109
- // never ran" and "ran, produced nothing" — logging the verdict here, next
110
- // to the literal command, is what actually tells the two apart.
111
- logDebug("SecurityValidator", `bashVerdict=${verdict} command=${command}`);
112
- if (reason) {
113
- process.stdout.write(blockResponse(`Blocked: ${reason}`, toolUse.hookEventName));
114
- process.exit(0);
115
- }
116
- }
23
+ process.stdout.write(blockResponse(refusal.message, refusal.hookEventName));
117
24
 
118
- const filePath = firstStringArg(toolUse.toolInput, ["file_path", "filePath", "path"]);
119
- if (writesFile(toolUse.toolName) && typeof filePath === "string") {
120
- const reason = checkFilePath(filePath);
121
- if (reason) {
122
- process.stdout.write(blockResponse(reason, toolUse.hookEventName));
123
- process.exit(0);
124
- }
25
+ // After the deny has gone out, in its own try/catch: this hook is fail-open,
26
+ // and a ledger that threw on its way to recording a block would turn the
27
+ // block into an allow.
28
+ try {
29
+ recordBlocked(refusal);
30
+ } catch (err) {
31
+ logError("SecurityValidator:ledger", err);
125
32
  }
126
33
  } catch {
127
34
  // Fail open