portable-agent-layer 0.61.4 → 0.62.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,111 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://raw.githubusercontent.com/kovrichard/portable-agent-layer/main/assets/schema/pal-settings.schema.json",
4
+ "title": "PAL settings",
5
+ "description": "Configuration for the Portable Agent Layer. Lives at ~/.pal/memory/pal-settings.json. All sections are optional; PAL falls back to defaults for anything absent.",
6
+ "type": "object",
7
+ "additionalProperties": true,
8
+ "properties": {
9
+ "$schema": {
10
+ "type": "string",
11
+ "description": "JSON Schema reference — enables editor autocomplete, validation, and hover docs."
12
+ },
13
+ "identity": {
14
+ "type": "object",
15
+ "description": "Who the assistant is and who it serves.",
16
+ "additionalProperties": false,
17
+ "properties": {
18
+ "ai": {
19
+ "type": "object",
20
+ "description": "The assistant's persona.",
21
+ "additionalProperties": false,
22
+ "properties": {
23
+ "name": { "type": "string", "description": "Short name (e.g. the name you call the assistant)." },
24
+ "fullName": { "type": "string", "description": "Full/formal name." },
25
+ "displayName": { "type": "string", "description": "Uppercase label used in headers." },
26
+ "catchphrase": { "type": "string", "description": "Greeting line. '{name}' is substituted with the ai.name value." }
27
+ }
28
+ },
29
+ "principal": {
30
+ "type": "object",
31
+ "description": "The human the assistant works for.",
32
+ "additionalProperties": false,
33
+ "properties": {
34
+ "name": { "type": "string", "description": "The principal's name." },
35
+ "timezone": { "type": "string", "description": "IANA timezone (e.g. Europe/Budapest)." }
36
+ }
37
+ }
38
+ }
39
+ },
40
+ "loadAtStartup": {
41
+ "type": "object",
42
+ "description": "Files force-loaded into session context at startup. Injected as <system-reminder> blocks.",
43
+ "additionalProperties": false,
44
+ "properties": {
45
+ "files": {
46
+ "type": "array",
47
+ "description": "Absolute or PAL-relative file paths to inject at session start.",
48
+ "items": { "type": "string" }
49
+ }
50
+ }
51
+ },
52
+ "dynamicContext": {
53
+ "type": "object",
54
+ "description": "Dynamic context sections injected at session start. Set any key to false to disable it; absent keys default to enabled.",
55
+ "additionalProperties": { "type": "boolean" },
56
+ "properties": {
57
+ "wisdom": { "type": "boolean", "description": "Crystallized wisdom principles." },
58
+ "opinions": { "type": "boolean", "description": "Tracked user opinions." },
59
+ "relationship": { "type": "boolean", "description": "Recent relationship notes." },
60
+ "learningDigest": { "type": "boolean", "description": "Lessons-from-failures digest." },
61
+ "failurePatterns": { "type": "boolean", "description": "Recurring failure patterns." },
62
+ "projectHistory": { "type": "boolean", "description": "Per-project session history." },
63
+ "projects": { "type": "boolean", "description": "Active projects list." },
64
+ "sessionIntelligence": { "type": "boolean", "description": "Rating trends and session signals." },
65
+ "handoff": { "type": "boolean", "description": "Handoff note from the previous session." },
66
+ "selfModel": { "type": "boolean", "description": "The assistant's self-model synthesis." },
67
+ "contextualSteering": { "type": "boolean", "description": "Prompt-time steering self-checks (see the steering section)." },
68
+ "steeringTestReport": { "type": "boolean", "description": "When true, the assistant notes to the user which steering self-check fired. For the dual-live test period; ships false." }
69
+ }
70
+ },
71
+ "attribution": {
72
+ "type": "object",
73
+ "description": "Git co-author attribution opt-in.",
74
+ "additionalProperties": false,
75
+ "properties": {
76
+ "enabled": { "type": "boolean", "description": "Add the PAL co-author trailer to commits." },
77
+ "decided": { "type": "boolean", "description": "Gates the one-time attribution prompt." }
78
+ }
79
+ },
80
+ "steering": {
81
+ "type": "object",
82
+ "description": "Contextual steering: PAL injects a short self-check into your prompt when it matches a task type. Toggle the whole feature via dynamicContext.contextualSteering.",
83
+ "additionalProperties": false,
84
+ "properties": {
85
+ "disable": {
86
+ "type": "array",
87
+ "description": "Shipped steering rules to suppress, by tag.",
88
+ "items": {
89
+ "type": "string",
90
+ "enum": ["debugging", "destructive", "refactor", "planning", "testing", "committing", "secrets"]
91
+ },
92
+ "uniqueItems": true
93
+ },
94
+ "rules": {
95
+ "type": "array",
96
+ "description": "Personal steering rules appended to the shipped set. A malformed entry (missing field or bad regex) is skipped, never fatal.",
97
+ "items": {
98
+ "type": "object",
99
+ "additionalProperties": false,
100
+ "required": ["tag", "pattern", "snippet"],
101
+ "properties": {
102
+ "tag": { "type": "string", "description": "Short identifier for this rule (also used to dedupe matches)." },
103
+ "pattern": { "type": "string", "description": "JavaScript regex source, matched case-insensitively against the prompt." },
104
+ "snippet": { "type": "string", "description": "The self-check text injected when the pattern matches." }
105
+ }
106
+ }
107
+ }
108
+ }
109
+ }
110
+ }
111
+ }
@@ -21,6 +21,10 @@
21
21
  {
22
22
  "type": "command",
23
23
  "bash": "PAL_AGENT=copilot bun run {{PKG_ROOT}}/src/hooks/SkillGuard.ts"
24
+ },
25
+ {
26
+ "type": "command",
27
+ "bash": "PAL_AGENT=copilot bun run {{PKG_ROOT}}/src/hooks/RtkWrap.ts"
24
28
  }
25
29
  ],
26
30
  "agentStop": [
@@ -21,6 +21,10 @@
21
21
  {
22
22
  "type": "command",
23
23
  "command": "PAL_AGENT=cursor bun run {{PKG_ROOT}}/src/hooks/SkillGuard.ts"
24
+ },
25
+ {
26
+ "type": "command",
27
+ "command": "PAL_AGENT=cursor bun run {{PKG_ROOT}}/src/hooks/RtkWrap.ts"
24
28
  }
25
29
  ],
26
30
  "beforeShellExecution": [
@@ -1,4 +1,5 @@
1
1
  {
2
+ "$schema": "https://raw.githubusercontent.com/kovrichard/portable-agent-layer/main/assets/schema/pal-settings.schema.json",
2
3
  "identity": {
3
4
  "ai": {
4
5
  "name": "",
@@ -12,11 +13,9 @@
12
13
  }
13
14
  },
14
15
  "loadAtStartup": {
15
- "_docs": "Files force-loaded into session context at startup. Injected as <system-reminder> blocks.",
16
16
  "files": []
17
17
  },
18
18
  "dynamicContext": {
19
- "_docs": "Dynamic context sections injected at session start. Set to false to disable.",
20
19
  "wisdom": true,
21
20
  "opinions": true,
22
21
  "relationship": true,
@@ -26,6 +25,12 @@
26
25
  "projects": true,
27
26
  "sessionIntelligence": true,
28
27
  "handoff": true,
29
- "selfModel": true
28
+ "selfModel": true,
29
+ "contextualSteering": true,
30
+ "steeringTestReport": false
31
+ },
32
+ "steering": {
33
+ "disable": [],
34
+ "rules": []
30
35
  }
31
36
  }
@@ -108,6 +108,15 @@
108
108
  "command": "PAL_AGENT=claude bun run {{PKG_ROOT}}/src/hooks/SkillGuard.ts"
109
109
  }
110
110
  ]
111
+ },
112
+ {
113
+ "matcher": "Bash",
114
+ "hooks": [
115
+ {
116
+ "type": "command",
117
+ "command": "PAL_AGENT=claude bun run {{PKG_ROOT}}/src/hooks/RtkWrap.ts"
118
+ }
119
+ ]
111
120
  }
112
121
  ],
113
122
  "Stop": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portable-agent-layer",
3
- "version": "0.61.4",
3
+ "version": "0.62.0",
4
4
  "description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli/index.ts CHANGED
@@ -622,6 +622,13 @@ function nodeInstallHint(): string {
622
622
  return "install Node ≥ 22.6 (see https://nodejs.org or your package manager)";
623
623
  }
624
624
 
625
+ function rtkInstallHint(): string {
626
+ if (process.platform === "win32")
627
+ return "download rtk.exe from https://github.com/rtk-ai/rtk/releases and add it to PATH";
628
+ if (process.platform === "darwin") return "`brew install rtk`";
629
+ return "`curl -fsSL https://raw.githubusercontent.com/rtk-ai/rtk/refs/heads/master/install.sh | sh`";
630
+ }
631
+
625
632
  function checkHookHealth(home: string): HookHealth {
626
633
  const stateDir = resolve(home, "memory", "state");
627
634
  // Read current + all rotated logs (`.1`..`.5`) + legacy `.prev` so a recent
@@ -671,6 +678,7 @@ interface DoctorResult {
671
678
  cursor: ToolCheck;
672
679
  copilot: ToolCheck;
673
680
  codex: ToolCheck;
681
+ rtk: ToolCheck;
674
682
  hasAgent: boolean;
675
683
  }
676
684
 
@@ -684,6 +692,7 @@ function doctor(silent = false): DoctorResult {
684
692
  cursor: { name: "cursor", available: true },
685
693
  copilot: { name: "copilot", available: true },
686
694
  codex: { name: "codex", available: true },
695
+ rtk: { name: "rtk", available: true },
687
696
  hasAgent: true,
688
697
  };
689
698
  }
@@ -694,6 +703,7 @@ function doctor(silent = false): DoctorResult {
694
703
  const cursor = checkTool("cursor");
695
704
  const copilot = checkTool("copilot", ["version"]);
696
705
  const codex = checkTool("codex");
706
+ const rtk = checkTool("rtk");
697
707
  const hasAgent =
698
708
  claude.available ||
699
709
  opencode.available ||
@@ -751,6 +761,11 @@ function doctor(silent = false): DoctorResult {
751
761
  : fail(
752
762
  "Playwright Chromium — not found (run 'pal cli install' or 'bunx playwright install chromium')"
753
763
  );
764
+ rtk.available
765
+ ? ok(rtk.version || "rtk")
766
+ : info(
767
+ `rtk — not installed (optional; enables Bash output compression — ${rtkInstallHint()})`
768
+ );
754
769
 
755
770
  console.log("");
756
771
  log.info("PAL state");
@@ -1023,7 +1038,7 @@ function doctor(silent = false): DoctorResult {
1023
1038
  console.log("");
1024
1039
  }
1025
1040
 
1026
- return { bun, claude, opencode, cursor, copilot, codex, hasAgent };
1041
+ return { bun, claude, opencode, cursor, copilot, codex, rtk, hasAgent };
1027
1042
  }
1028
1043
 
1029
1044
  // ── Commands ──
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Hook: PreToolUse — transparently wraps Bash commands in `rtk` to compress
3
+ * tool output before it reaches the model's context.
4
+ *
5
+ * PAL owns the wiring + presence-gating; rtk owns the rewrite. We do NOT
6
+ * reimplement rtk's selectivity or per-agent JSON shape — we forward the hook's
7
+ * stdin to `rtk hook <agent>` and pass its stdout straight back. rtk emits the
8
+ * correct rewrite object for each agent (Claude/Cursor/Copilot all differ), or
9
+ * nothing when a command isn't worth rewriting.
10
+ *
11
+ * Fail-open by construction: if rtk isn't installed, the agent can't rewrite,
12
+ * or anything throws, we emit nothing and exit 0 — the original command runs
13
+ * unchanged. rtk absent must never block or alter a Bash call.
14
+ *
15
+ * Codex (allow/deny only) and opencode (plugin path) have no `rtk hook`
16
+ * subcommand and are handled elsewhere — this hook no-ops for them.
17
+ */
18
+
19
+ import { getActiveAgent } from "./lib/agent";
20
+ import { findBinaryOnPath } from "./lib/which";
21
+
22
+ const RTK_HOOK_SUBCOMMAND: Partial<Record<ReturnType<typeof getActiveAgent>, string>> = {
23
+ claude: "claude",
24
+ cursor: "cursor",
25
+ copilot: "copilot",
26
+ };
27
+
28
+ async function run(): Promise<void> {
29
+ const subcommand = RTK_HOOK_SUBCOMMAND[getActiveAgent()];
30
+ if (!subcommand) return; // codex/opencode — no rtk hook processor
31
+
32
+ const rtk = findBinaryOnPath("rtk");
33
+ if (!rtk) return; // fail-open: rtk not installed → command runs unchanged
34
+
35
+ const stdin = await Bun.stdin.text();
36
+ const proc = Bun.spawn([rtk, "hook", subcommand], {
37
+ stdin: Buffer.from(stdin),
38
+ stdout: "pipe",
39
+ stderr: "ignore", // drop rtk's "no hook installed" stderr nag
40
+ });
41
+ const out = await new Response(proc.stdout).text();
42
+ await proc.exited;
43
+ if (proc.exitCode === 0 && out) {
44
+ process.stdout.write(out);
45
+ }
46
+ }
47
+
48
+ try {
49
+ await run();
50
+ } catch {
51
+ // Fail open — emit nothing, allow the original command.
52
+ }
@@ -7,7 +7,7 @@
7
7
  * - session-name: generate 4-word session headline on first prompt
8
8
  */
9
9
 
10
- import { injectRetrieval } from "./handlers/inject-retrieval";
10
+ import { injectPromptContext } from "./handlers/inject-retrieval";
11
11
  import { captureRating } from "./handlers/rating";
12
12
  import { captureSessionName } from "./handlers/session-name";
13
13
  import { logDebug, logError, logPromptSnapshot } from "./lib/log";
@@ -30,8 +30,8 @@ logDebug("UserPromptOrchestrator", `Input: ${JSON.stringify(input).slice(0, 200)
30
30
  if (!input?.prompt) process.exit(0);
31
31
 
32
32
  const sessionId = input.session_id ?? input.sessionId ?? input.conversation_id;
33
- const retrieval = await injectRetrieval(input.prompt);
34
- logPromptSnapshot(input.prompt, retrieval);
33
+ const injected = await injectPromptContext(input.prompt);
34
+ logPromptSnapshot(input.prompt, injected);
35
35
 
36
36
  const results = await Promise.allSettled([
37
37
  captureRating(input.prompt, sessionId),
@@ -12,6 +12,7 @@ import { logDebug, logError } from "../lib/log";
12
12
  import { runRetrieval } from "../lib/retrieval";
13
13
  import { ensureIndex } from "../lib/retrieval-index";
14
14
  import { isEnabled } from "../lib/settings";
15
+ import { getSteeringReminder } from "../lib/steering";
15
16
 
16
17
  const TIMEOUT_MS = 250;
17
18
 
@@ -51,12 +52,11 @@ export async function getRetrievalReminder(prompt: string): Promise<string | nul
51
52
  return result.reminder;
52
53
  }
53
54
 
54
- /** Write retrieval reminder to stdout in the correct format for the current agent.
55
+ /** Write a reminder to stdout in the correct format for the current agent.
55
56
  * Claude Code: plain text. Cursor: { additional_context }. Codex: hookSpecificOutput JSON.
56
- * Returns the reminder string that was injected, or null if nothing was injected. */
57
- export async function injectRetrieval(prompt: string): Promise<string | null> {
58
- const reminder = await getRetrievalReminder(prompt);
59
- if (!reminder) return null;
57
+ * MUST be called at most once per hook run Cursor/Codex expect a single JSON
58
+ * object on stdout, so all prompt-time context is merged before this call. */
59
+ function writeForAgent(reminder: string): void {
60
60
  if (isCursor()) {
61
61
  process.stdout.write(JSON.stringify({ additional_context: reminder }));
62
62
  } else if (isCodex()) {
@@ -71,5 +71,17 @@ export async function injectRetrieval(prompt: string): Promise<string | null> {
71
71
  } else {
72
72
  process.stdout.write(`${reminder}\n`);
73
73
  }
74
- return reminder;
74
+ }
75
+
76
+ /** Gather all prompt-time context — prior-lesson retrieval + contextual steering —
77
+ * merge into a single payload, and do the one per-agent write. Returns the combined
78
+ * reminder that was injected, or null if there was nothing to inject. */
79
+ export async function injectPromptContext(prompt: string): Promise<string | null> {
80
+ const retrieval = await getRetrievalReminder(prompt);
81
+ const steering = getSteeringReminder(prompt);
82
+ const parts = [steering, retrieval].filter((p): p is string => Boolean(p));
83
+ if (parts.length === 0) return null;
84
+ const combined = parts.join("\n\n");
85
+ writeForAgent(combined);
86
+ return combined;
75
87
  }
@@ -20,8 +20,7 @@
20
20
  * yet wired and currently fall through to the API path.
21
21
  */
22
22
 
23
- import { accessSync, constants, existsSync } from "node:fs";
24
- import { basename, delimiter, resolve as resolvePath } from "node:path";
23
+ import { basename } from "node:path";
25
24
  import {
26
25
  getActiveAgent,
27
26
  isClaude,
@@ -33,6 +32,7 @@ import {
33
32
  import { logDebug } from "./log";
34
33
  import { HAIKU_MODEL } from "./models";
35
34
  import { buildSpawnGuardEnv, getInferenceDepth, SPAWN_GUARD_ENV } from "./spawn-guard";
35
+ import { findBinaryOnPath } from "./which";
36
36
 
37
37
  export function hasApiKey(): boolean {
38
38
  return !!process.env.PAL_ANTHROPIC_API_KEY;
@@ -213,45 +213,6 @@ let opencodeBinaryCache: string | null | undefined;
213
213
  let copilotBinaryCache: string | null | undefined;
214
214
  let cursorBinaryCache: string | null | undefined;
215
215
 
216
- /**
217
- * Resolve a binary on PATH to its full absolute path.
218
- *
219
- * Manual PATH walk (instead of Bun.which / `which` subprocess) because:
220
- * 1. Ubuntu 24.04 dropped the `which` binary entirely.
221
- * 2. Windows has no `which` at all.
222
- * 3. Bun.which snapshots PATH at startup and ignores mid-test mutations.
223
- * 4. Bun.spawn on Windows is inconsistent at resolving PATHEXT for bare
224
- * names — passing the full `.cmd`/`.exe` path bypasses that fragility.
225
- *
226
- * Returns the resolved absolute path or null.
227
- */
228
- function findBinaryOnPath(name: string): string | null {
229
- const PATH = process.env.PATH;
230
- if (!PATH) return null;
231
- const exts =
232
- process.platform === "win32"
233
- ? (process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";")
234
- : [""];
235
- for (const dir of PATH.split(delimiter)) {
236
- if (!dir) continue;
237
- for (const ext of exts) {
238
- const candidate = resolvePath(dir, name + ext);
239
- try {
240
- if (process.platform === "win32") {
241
- // Windows has no executable bit — existence in PATHEXT is enough.
242
- if (existsSync(candidate)) return candidate;
243
- } else {
244
- accessSync(candidate, constants.X_OK);
245
- return candidate;
246
- }
247
- } catch {
248
- /* not here — try next */
249
- }
250
- }
251
- }
252
- return null;
253
- }
254
-
255
216
  function getClaudeBinary(): string | null {
256
217
  if (claudeBinaryCache !== undefined) return claudeBinaryCache;
257
218
  claudeBinaryCache = findBinaryOnPath("claude");
@@ -25,6 +25,11 @@ export interface PalSettingsData {
25
25
  dynamicContext?: Record<string, boolean>;
26
26
  /** Git co-author attribution opt-in. `decided` gates the one-time prompt. */
27
27
  attribution?: { enabled?: boolean; decided?: boolean };
28
+ /** Contextual-steering user extension: personal rules + shipped rules to suppress by tag. */
29
+ steering?: {
30
+ disable?: string[];
31
+ rules?: Array<{ tag: string; pattern: string; snippet: string }>;
32
+ };
28
33
  [key: string]: unknown;
29
34
  }
30
35
 
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Contextual Steering — deterministic prompt-type classifier that injects
3
+ * task-specific steering into the model's context at prompt time.
4
+ *
5
+ * Pure + fail-open: classifyPrompt is a regex table (no LLM, sub-millisecond).
6
+ * getSteeringReminder wraps matched snippets in a single <system-reminder>,
7
+ * byte-capped. It rides the same UserPromptSubmit path as inject-retrieval; the
8
+ * per-agent output adapter is shared (see inject-retrieval's writeForAgent).
9
+ *
10
+ * This is the prompt-time counterpart to STEERING_RULES.md: the always-on file
11
+ * keeps terse directives, while the verbose guidance lives here and is injected
12
+ * only when the prompt matches that task type.
13
+ */
14
+
15
+ import { isEnabled, raw } from "./settings";
16
+
17
+ /** The tags shipped as built-in defaults. Users may add their own tags via
18
+ * pal-settings.json `steering.rules`, so the effective tag set is open-ended. */
19
+ type SteeringTag =
20
+ | "debugging"
21
+ | "destructive"
22
+ | "refactor"
23
+ | "planning"
24
+ | "testing"
25
+ | "committing"
26
+ | "secrets";
27
+
28
+ interface SteeringRule {
29
+ tag: string;
30
+ pattern: RegExp;
31
+ snippet: string;
32
+ }
33
+
34
+ // Ordered rule table. Multiple rules may match one prompt; classifyPrompt
35
+ // returns every match in declaration order. Patterns are anchored on word
36
+ // boundaries to avoid substring false-positives (e.g. "warm" ≠ "rm").
37
+ const STEERING_RULES: Array<SteeringRule & { tag: SteeringTag }> = [
38
+ {
39
+ tag: "debugging",
40
+ pattern:
41
+ /\b(bugs?|broken|failing|fails?|errors?|crash\w*|regress\w*|debug\w*|not working|doesn'?t work|stack ?trace)\b/i,
42
+ snippet:
43
+ "Debugging something? If so, change one thing at a time and reproduce the failure before fixing — keep the correction surgical rather than a rewrite.",
44
+ },
45
+ {
46
+ tag: "destructive",
47
+ pattern:
48
+ /\b(delete|remove|drop|rm\s+-rf|force[- ]push|reset\s+--hard|wipe|truncate|prune)\b/i,
49
+ snippet:
50
+ "About to delete, force-push, or drop something irreversible? If so, list what's affected and confirm before acting — and if what you find contradicts how it was described, surface that first.",
51
+ },
52
+ {
53
+ tag: "refactor",
54
+ pattern:
55
+ /\b(refactor\w*|clean\s?up|simplif\w*|reorganiz\w*|restructur\w*|dead code|tech debt)\b/i,
56
+ snippet:
57
+ "Refactoring or cleaning up? If so, prefer first-principles simplification over adding new layers, and keep the change scoped to what was asked — flag dead code rather than silently rewriting neighboring files.",
58
+ },
59
+ {
60
+ tag: "planning",
61
+ pattern: /\b(plans?|planning|designs?|designing|architect\w*|roadmap)\b/i,
62
+ snippet:
63
+ "Planning or designing something? If you were asked to plan, present the approach and stop — wait for an explicit go-ahead before writing any code.",
64
+ },
65
+ {
66
+ tag: "testing",
67
+ pattern: /\b(tests?|testing|coverage|assertions?|vacuous)\b/i,
68
+ snippet:
69
+ "Adding or changing a test? Prove it isn't vacuous — make it fail first for the right reason, then restore it, so a green run actually means something.",
70
+ },
71
+ {
72
+ tag: "committing",
73
+ pattern: /\b(commits?|committing|pull requests?|cherry-pick|rebase|PR)\b|git push/i,
74
+ snippet:
75
+ "About to commit, push, or open a PR? Only do it if it was asked, and keep the commit scoped to exactly what was requested.",
76
+ },
77
+ {
78
+ tag: "secrets",
79
+ pattern: /\b(secrets?|api[\s-]?keys?|tokens?|passwords?|credentials?)\b|\.env\b/i,
80
+ snippet:
81
+ "Handling secrets, API keys, or credentials? Never hardcode them in source or commit them — keep them in env or private config, and never echo them into logs or output.",
82
+ },
83
+ ];
84
+
85
+ const MAX_STEERING_BYTES = 1000;
86
+
87
+ /** Merge shipped defaults with the user's pal-settings.json extension:
88
+ * `steering.disable` removes built-ins by tag; `steering.rules` appends personal
89
+ * rules. Malformed user entries (missing field or bad regex) are skipped, never
90
+ * thrown — a broken personal rule must not disable steering for everyone. */
91
+ function effectiveRules(): SteeringRule[] {
92
+ const cfg = raw().steering ?? {};
93
+ const disabled = new Set(cfg.disable ?? []);
94
+ const rules: SteeringRule[] = STEERING_RULES.filter((r) => !disabled.has(r.tag));
95
+ for (const u of cfg.rules ?? []) {
96
+ if (!u?.tag || !u?.pattern || !u?.snippet) continue;
97
+ let pattern: RegExp;
98
+ try {
99
+ pattern = new RegExp(u.pattern, "i");
100
+ } catch {
101
+ continue; // fail-open: skip malformed regex
102
+ }
103
+ rules.push({ tag: u.tag, pattern, snippet: u.snippet });
104
+ }
105
+ return rules;
106
+ }
107
+
108
+ /** Match a prompt against the effective rule set, one hit per tag, in order. */
109
+ function matchRules(text: string): SteeringRule[] {
110
+ if (!text?.trim()) return [];
111
+ const matched: SteeringRule[] = [];
112
+ const seen = new Set<string>();
113
+ for (const rule of effectiveRules()) {
114
+ if (!seen.has(rule.tag) && rule.pattern.test(text)) {
115
+ seen.add(rule.tag);
116
+ matched.push(rule);
117
+ }
118
+ }
119
+ return matched;
120
+ }
121
+
122
+ /** Deterministically classify a prompt into steering tags. Pure, fail-open. */
123
+ export function classifyPrompt(text: string): string[] {
124
+ return matchRules(text).map((r) => r.tag);
125
+ }
126
+
127
+ /** Build the steering <system-reminder> for a prompt, or null if nothing matches. */
128
+ export function getSteeringReminder(prompt: string): string | null {
129
+ if (!isEnabled("contextualSteering")) return null;
130
+
131
+ let matched: SteeringRule[];
132
+ try {
133
+ matched = matchRules(prompt);
134
+ } catch {
135
+ return null; // fail-open: never block a prompt on a classifier error
136
+ }
137
+ if (matched.length === 0) return null;
138
+
139
+ const lines: string[] = [];
140
+ let budget = MAX_STEERING_BYTES;
141
+ for (const rule of matched) {
142
+ const line = `- ${rule.snippet}`;
143
+ const cost = Buffer.byteLength(line);
144
+ if (cost > budget) break; // byte-cap: drop the overflow tail, keep top matches
145
+ lines.push(line);
146
+ budget -= cost;
147
+ }
148
+ if (lines.length === 0) return null;
149
+
150
+ const inner = [
151
+ "Heuristic self-checks auto-matched on your prompt — they may be off; ignore any that don't apply:",
152
+ ...lines,
153
+ ];
154
+ // Dual-live test period: co-locate the report instruction with the trigger so
155
+ // it fires reliably across sessions/compaction. Flip steeringTestReport off to end it.
156
+ if (isEnabled("steeringTestReport")) {
157
+ inner.push(
158
+ "— Test period: assistant, briefly note to the user that a steering self-check fired, and which one(s)."
159
+ );
160
+ }
161
+ return ["<system-reminder>", ...inner, "</system-reminder>"].join("\n");
162
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Cross-platform binary resolution — a manual PATH walk.
3
+ *
4
+ * Deliberately avoids Bun.which / a `which` subprocess because:
5
+ * 1. Ubuntu 24.04 dropped the `which` binary entirely.
6
+ * 2. Windows has no `which` at all.
7
+ * 3. Bun.which snapshots PATH at startup and ignores mid-test mutations.
8
+ * 4. Bun.spawn on Windows is inconsistent at resolving PATHEXT for bare
9
+ * names — passing the full `.cmd`/`.exe` path bypasses that fragility.
10
+ */
11
+
12
+ import { accessSync, constants, existsSync } from "node:fs";
13
+ import { delimiter, resolve as resolvePath } from "node:path";
14
+
15
+ /** Resolve a binary on PATH to its full absolute path, or null if absent. */
16
+ export function findBinaryOnPath(name: string): string | null {
17
+ const PATH = process.env.PATH;
18
+ if (!PATH) return null;
19
+ const exts =
20
+ process.platform === "win32"
21
+ ? (process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";")
22
+ : [""];
23
+ for (const dir of PATH.split(delimiter)) {
24
+ if (!dir) continue;
25
+ for (const ext of exts) {
26
+ 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
+ }
38
+ }
39
+ }
40
+ return null;
41
+ }
@@ -5,6 +5,7 @@
5
5
  * This plugin just wires opencode's hook API to those shared functions.
6
6
  */
7
7
 
8
+ import { spawnSync } from "node:child_process";
8
9
  import { resolve } from "node:path";
9
10
  import type { Plugin, PluginInput } from "@opencode-ai/plugin";
10
11
 
@@ -30,6 +31,27 @@ const PALPlugin: Plugin = async ({ directory, client }: PluginInput) => {
30
31
  await lib<typeof import("../../hooks/lib/context")>("context.ts");
31
32
  const { checkBashCommand, checkFilePath } =
32
33
  await lib<typeof import("../../hooks/lib/security")>("security.ts");
34
+ const { findBinaryOnPath } =
35
+ await lib<typeof import("../../hooks/lib/which")>("which.ts");
36
+
37
+ // rtk output compression — PAL gates on presence, rtk owns the rewrite.
38
+ // `rtk hook check <cmd>` prints the rewritten command (exit 0) or nothing
39
+ // (exit 1) when a command isn't worth wrapping. Fail-open: absent rtk or any
40
+ // error leaves the command untouched.
41
+ const rtkBin = findBinaryOnPath("rtk");
42
+ const rtkRewrite = (cmd: string): string | null => {
43
+ if (!rtkBin || !cmd) return null;
44
+ try {
45
+ const r = spawnSync(rtkBin, ["hook", "check", cmd], {
46
+ encoding: "utf8",
47
+ stdio: ["ignore", "pipe", "ignore"],
48
+ });
49
+ const out = r.status === 0 ? (r.stdout ?? "").trim() : "";
50
+ return out && out !== cmd ? out : null;
51
+ } catch {
52
+ return null;
53
+ }
54
+ };
33
55
  const { logDebug, logError, logPromptSnapshot } =
34
56
  await lib<typeof import("../../hooks/lib/log")>("log.ts");
35
57
 
@@ -44,6 +66,8 @@ const PALPlugin: Plugin = async ({ directory, client }: PluginInput) => {
44
66
  const { getRetrievalReminder } = await lib<
45
67
  typeof import("../../hooks/handlers/inject-retrieval")
46
68
  >("../handlers/inject-retrieval.ts");
69
+ const { getSteeringReminder } =
70
+ await lib<typeof import("../../hooks/lib/steering")>("steering.ts");
47
71
 
48
72
  function partsToText(parts: Array<Record<string, unknown>>): string {
49
73
  return parts
@@ -140,20 +164,22 @@ const PALPlugin: Plugin = async ({ directory, client }: PluginInput) => {
140
164
  if (!text.trim()) return;
141
165
 
142
166
  const retrieval = await getRetrievalReminder(text);
143
- logPromptSnapshot(text, retrieval);
167
+ const steering = getSteeringReminder(text);
168
+ const injectedText = [steering, retrieval].filter(Boolean).join("\n\n");
169
+ logPromptSnapshot(text, injectedText || null);
144
170
 
145
171
  await Promise.allSettled([
146
172
  captureRating(text, input.sessionID),
147
173
  captureSessionName(text, input.sessionID),
148
174
  ]);
149
175
 
150
- if (retrieval) {
176
+ if (injectedText) {
151
177
  const injected = {
152
- id: `pal-retrieval-${Date.now()}`,
178
+ id: `pal-promptctx-${Date.now()}`,
153
179
  sessionID: input.sessionID,
154
180
  messageID: input.messageID ?? `pal-msg-${Date.now()}`,
155
181
  type: "text" as const,
156
- text: retrieval,
182
+ text: injectedText,
157
183
  synthetic: true,
158
184
  };
159
185
  output.parts = [injected, ...(output.parts ?? [])];
@@ -176,6 +202,11 @@ const PALPlugin: Plugin = async ({ directory, client }: PluginInput) => {
176
202
  if (reason) {
177
203
  throw new Error(`PAL Security: Blocked — ${reason}`);
178
204
  }
205
+ const rewritten = rtkRewrite(cmd);
206
+ if (rewritten) {
207
+ if (typeof output.args === "string") output.args = rewritten;
208
+ else (output.args as Record<string, unknown>).command = rewritten;
209
+ }
179
210
  }
180
211
 
181
212
  if (toolName === "write" || toolName === "edit" || toolName === "patch") {
@@ -15,6 +15,7 @@
15
15
  import { appendFileSync } from "node:fs";
16
16
  import { parseArgs } from "node:util";
17
17
  import { paths } from "../../hooks/lib/paths";
18
+ import { emit } from "../lib/emit";
18
19
 
19
20
  // ── Types ──
20
21
 
@@ -121,7 +122,7 @@ Output: algorithm-reflections.jsonl in memory/learning/reflections/
121
122
  };
122
123
 
123
124
  const result = appendReflection(reflection);
124
- console.log(JSON.stringify(result, null, 2));
125
+ emit.ok(result.message);
125
126
  }
126
127
 
127
128
  if (import.meta.main) run();
@@ -14,6 +14,7 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs";
14
14
  import { resolve } from "node:path";
15
15
  import { parseArgs } from "node:util";
16
16
  import { ensureDir, paths } from "../../hooks/lib/paths";
17
+ import { emit } from "../lib/emit";
17
18
 
18
19
  interface HandoffEntry {
19
20
  timestamp: string;
@@ -102,7 +103,7 @@ Output: writes to memory/state/last-handoff.json keyed by cwd
102
103
  values.text || "",
103
104
  true
104
105
  );
105
- console.log(JSON.stringify(result, null, 2));
106
+ emit.ok(result.message);
106
107
  process.exit(0);
107
108
  }
108
109
 
@@ -112,7 +113,7 @@ Output: writes to memory/state/last-handoff.json keyed by cwd
112
113
  }
113
114
 
114
115
  const result = writeHandoffNote(process.cwd(), values.title, values.text, false);
115
- console.log(JSON.stringify(result, null, 2));
116
+ emit.ok(result.message);
116
117
  }
117
118
 
118
119
  if (import.meta.main) run();
@@ -123,10 +123,24 @@ function cmdCreate(args: string[]): void {
123
123
 
124
124
  // ── resume ────────────────────────────────────────────────────────
125
125
 
126
+ // resume returns a lean orientation view: all narrative sections, but the
127
+ // Criteria/Changelog blobs collapse to open-ISC titles + counts. Full ISC text
128
+ // is fetched on demand via show-isc / list-isc, so resume stays cheap on
129
+ // projects carrying a large backlog.
126
130
  function cmdResume(args: string[]): void {
127
131
  const name = args[0];
128
132
  if (!name) fail("Usage: resume <name>");
129
- ok({ project: requireProject(name) });
133
+ const { criteria, changelog, ...project } = requireProject(name);
134
+ const iscs = parseIscs(criteria ?? "");
135
+ const openIscs = iscs.filter((i) => !i.checked);
136
+ const done = iscs.filter((i) => i.checked).length + parseIscs(changelog ?? "").length;
137
+ ok({
138
+ project: {
139
+ ...project,
140
+ open_iscs: openIscs.map((i) => ({ id: i.id, title: iscTitle(i.text) })),
141
+ isc_summary: { open: openIscs.length, done },
142
+ },
143
+ });
130
144
  }
131
145
 
132
146
  // ── status transitions ────────────────────────────────────────────
@@ -351,6 +365,14 @@ function parseIscs(criteria: string): Isc[] {
351
365
  return out;
352
366
  }
353
367
 
368
+ // Collapse a full ISC line to a glanceable title for resume: cut at the first
369
+ // clause boundary, then hard-cap length. Full text stays reachable via show-isc.
370
+ function iscTitle(text: string): string {
371
+ const boundary = text.search(/; | — | \(|\. /);
372
+ const clause = (boundary > 0 ? text.slice(0, boundary) : text).trim();
373
+ return clause.length > 80 ? `${clause.slice(0, 79).trimEnd()}…` : clause;
374
+ }
375
+
354
376
  // Scans Criteria AND Changelog so an archived id can never be handed out again.
355
377
  function nextIscId(p: ProjectProgress): number {
356
378
  const ids = [...parseIscs(p.criteria ?? ""), ...parseIscs(p.changelog ?? "")].map(
@@ -493,6 +515,20 @@ function cmdListIsc(args: string[]): void {
493
515
  });
494
516
  }
495
517
 
518
+ // show-isc prints one ISC's full text on demand — the "detail" counterpart to
519
+ // resume's titles. Scans Criteria (open + not-yet-archived) and Changelog.
520
+ function cmdShowIsc(args: string[]): void {
521
+ const name = args[0];
522
+ const id = Number(args[1]);
523
+ if (!name || !Number.isInteger(id) || id < 1) fail("Usage: show-isc <name> <id>");
524
+ const p = requireProject(name);
525
+ const isc = [...parseIscs(p.criteria ?? ""), ...parseIscs(p.changelog ?? "")].find(
526
+ (i) => i.id === id
527
+ );
528
+ if (!isc) fail(`ISC-${id} not found in project "${name}".`);
529
+ ok({ name, id: isc.id, status: isc.checked ? "closed" : "open", text: isc.text });
530
+ }
531
+
496
532
  // Backfill: sweep any done ISCs still sitting in Criteria (legacy projects, or
497
533
  // completions from before archive-on-complete) into the Changelog in one pass.
498
534
  function cmdPruneIsc(args: string[]): void {
@@ -576,7 +612,7 @@ function help(): void {
576
612
  Commands:
577
613
  list show all registered projects
578
614
  create [name] [--path PATH] [--objectives X] register a project
579
- resume <name> print full project ISA
615
+ resume <name> print lean project view (open-ISC titles; full text via show-isc)
580
616
  complete <name> mark complete
581
617
  archive <name> mark archived
582
618
  pause <name> | unpause <name> toggle paused/active
@@ -593,6 +629,7 @@ Commands:
593
629
  complete-isc <name> <id> mark ISC-N as done
594
630
  reopen-isc <name> <id> reopen ISC-N (mark not done)
595
631
  list-isc <name> [--all | --closed] list open ISCs (default); --all or --closed for done
632
+ show-isc <name> <id> print one ISC's full text
596
633
  prune-isc <name> archive done ISCs from Criteria into the Changelog
597
634
  isa-init <name> mark project as ISA-initialized
598
635
  scaffold-task-isa <title> create a one-shot task ISA in memory/work/
@@ -685,6 +722,9 @@ function run(): void {
685
722
  case "list-isc":
686
723
  cmdListIsc(rest);
687
724
  return;
725
+ case "show-isc":
726
+ cmdShowIsc(rest);
727
+ return;
688
728
  case "prune-isc":
689
729
  cmdPruneIsc(rest);
690
730
  return;
@@ -6,8 +6,8 @@
6
6
  * the user (O, W) and session diary entries (--b).
7
7
  *
8
8
  * Usage:
9
- * bun ~/.pal/tools/relationship-note.ts --o "Rico prefers X" --confidence 0.80
10
- * bun ~/.pal/tools/relationship-note.ts --w "Rico is building X in TypeScript"
9
+ * bun ~/.pal/tools/relationship-note.ts --o "User prefers X" --confidence 0.80
10
+ * bun ~/.pal/tools/relationship-note.ts --w "User is building X in TypeScript"
11
11
  * bun ~/.pal/tools/relationship-note.ts --b "Debugged the cache split logic"
12
12
  *
13
13
  * Note types:
@@ -18,6 +18,7 @@
18
18
 
19
19
  import { parseArgs } from "node:util";
20
20
  import { appendNotes } from "../../hooks/lib/relationship";
21
+ import { emit } from "../lib/emit";
21
22
 
22
23
  function run() {
23
24
  const { values } = parseArgs({
@@ -36,8 +37,8 @@ function run() {
36
37
  RelationshipNote — Append W/O/Session entries to today's relationship log
37
38
 
38
39
  Usage:
39
- bun ~/.pal/tools/relationship-note.ts --o "Rico prefers X" --confidence 0.80
40
- bun ~/.pal/tools/relationship-note.ts --w "Rico is building X in TypeScript"
40
+ bun ~/.pal/tools/relationship-note.ts --o "User prefers X" --confidence 0.80
41
+ bun ~/.pal/tools/relationship-note.ts --w "User is building X in TypeScript"
41
42
  bun ~/.pal/tools/relationship-note.ts --b "Debugged the cache split logic"
42
43
 
43
44
  Flags:
@@ -83,13 +84,7 @@ Output: appends to memory/relationship/YYYY-MM/YYYY-MM-DD.md
83
84
 
84
85
  appendNotes(notes);
85
86
 
86
- console.log(
87
- JSON.stringify(
88
- { success: true, message: "Relationship note written", count: notes.length },
89
- null,
90
- 2
91
- )
92
- );
87
+ emit.ok(`Relationship note written (${notes.length})`);
93
88
  }
94
89
 
95
90
  if (import.meta.main) run();
@@ -15,6 +15,7 @@ import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs
15
15
  import { resolve } from "node:path";
16
16
  import { parseArgs } from "node:util";
17
17
  import { ensureDir, paths } from "../../hooks/lib/paths";
18
+ import { emit } from "../lib/emit";
18
19
 
19
20
  // ── Types ──
20
21
 
@@ -137,13 +138,7 @@ Usage:
137
138
  process.exit(1);
138
139
  }
139
140
  const thread = addThread(values.title, values.context ?? "");
140
- console.log(
141
- JSON.stringify(
142
- { success: true, id: thread.id, message: `Thread added: ${thread.title}` },
143
- null,
144
- 2
145
- )
146
- );
141
+ emit.data(`Added with id ${thread.id}`);
147
142
  }
148
143
 
149
144
  if (cmd === "resolve") {
@@ -151,12 +146,14 @@ Usage:
151
146
  console.error("--id required");
152
147
  process.exit(1);
153
148
  }
154
- console.log(JSON.stringify(resolveThread(values.id), null, 2));
149
+ const resolved = resolveThread(values.id);
150
+ if (resolved.success) emit.ok(resolved.message ?? "Thread resolved");
151
+ else emit.data(JSON.stringify(resolved, null, 2));
155
152
  }
156
153
 
157
154
  if (cmd === "list") {
158
155
  const threads = listThreads(values.all ?? false);
159
- console.log(JSON.stringify({ count: threads.length, threads }, null, 2));
156
+ emit.data(JSON.stringify({ count: threads.length, threads }, null, 2));
160
157
  }
161
158
  }
162
159
 
@@ -19,6 +19,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
19
19
  import { resolve } from "node:path";
20
20
  import { parseArgs } from "node:util";
21
21
  import { paths } from "../../hooks/lib/paths";
22
+ import { emit } from "../lib/emit";
22
23
 
23
24
  // ── Types ──
24
25
 
@@ -233,7 +234,7 @@ Examples:
233
234
 
234
235
  const cliType = (values.type || "evolution") as ObservationType;
235
236
  const result = updateFrame(values.domain, values.observation, cliType);
236
- console.log(JSON.stringify(result, null, 2));
237
+ emit.ok(result.message);
237
238
  }
238
239
 
239
240
  if (import.meta.main) run();
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Output convention for agent-invoked CLI tools: quiet on success, full on
3
+ * failure. The agent captures tool output over a pipe (non-TTY), where success
4
+ * confirmations are pure noise that costs context tokens; a human at a TTY wants
5
+ * them. Gate on the TTY signal, with PAL_VERBOSE / PAL_QUIET as explicit overrides.
6
+ *
7
+ * data() requested payload — ALWAYS emitted (list output, reports, results)
8
+ * ok() success confirmation / progress — only at a TTY or under PAL_VERBOSE
9
+ *
10
+ * Errors stay on console.error (stderr) + a non-zero exit — always surfaced,
11
+ * independent of this gate.
12
+ */
13
+
14
+ function isVerbose(): boolean {
15
+ if (process.env.PAL_QUIET === "1") return false;
16
+ if (process.env.PAL_VERBOSE === "1") return true;
17
+ return Boolean(process.stdout.isTTY);
18
+ }
19
+
20
+ function line(stream: { write: (s: string) => void }, text: string): void {
21
+ stream.write(text.endsWith("\n") ? text : `${text}\n`);
22
+ }
23
+
24
+ export const emit = {
25
+ data(text: string): void {
26
+ line(process.stdout, text);
27
+ },
28
+ ok(text: string): void {
29
+ if (isVerbose()) line(process.stdout, text);
30
+ },
31
+ };
@@ -26,6 +26,7 @@ import {
26
26
  } from "../hooks/lib/opinions";
27
27
  import { palHome } from "../hooks/lib/paths";
28
28
  import { similarity } from "../hooks/lib/text-similarity";
29
+ import { emit } from "./lib/emit";
29
30
 
30
31
  // ── Types ──
31
32
 
@@ -413,11 +414,11 @@ Output:
413
414
  const notes = loadNotes(daysBack);
414
415
  const ratings = loadRatings(daysBack);
415
416
 
416
- console.log(`Loaded ${notes.length} notes from last ${daysBack} days`);
417
- console.log(`Loaded ${ratings.length} ratings`);
417
+ emit.ok(`Loaded ${notes.length} notes from last ${daysBack} days`);
418
+ emit.ok(`Loaded ${ratings.length} ratings`);
418
419
 
419
420
  if (notes.length === 0 && ratings.length === 0) {
420
- console.log("No data to analyze");
421
+ emit.ok("No data to analyze");
421
422
  process.exit(0);
422
423
  }
423
424
 
@@ -425,42 +426,42 @@ Output:
425
426
 
426
427
  const avgRating =
427
428
  ratings.length > 0 ? ratings.reduce((s, r) => s + r.rating, 0) / ratings.length : 0;
428
- console.log(`\nAverage Rating: ${avgRating.toFixed(1)}/10`);
429
+ emit.ok(`\nAverage Rating: ${avgRating.toFixed(1)}/10`);
429
430
 
430
431
  const summaries = groupNoteOccurrences(notes);
431
- console.log(`Observations: ${summaries.length} unique`);
432
+ emit.ok(`Observations: ${summaries.length} unique`);
432
433
 
433
434
  if (opinionChanges.length > 0) {
434
- console.log("\nOpinion changes:");
435
+ emit.ok("\nOpinion changes:");
435
436
  for (const change of opinionChanges) {
436
437
  if (change.action === "created") {
437
- console.log(
438
+ emit.ok(
438
439
  ` + NEW (${Math.round(change.newConfidence * 100)}%) ${change.statement.slice(0, 80)}`
439
440
  );
440
441
  } else {
441
- console.log(
442
+ emit.ok(
442
443
  ` ~ ${Math.round(change.oldConfidence ?? 0 * 100)}% → ${Math.round(change.newConfidence * 100)}% ${change.statement.slice(0, 80)}`
443
444
  );
444
445
  }
445
446
  }
446
447
  } else {
447
- console.log("\nNo opinion changes");
448
+ emit.ok("\nNo opinion changes");
448
449
  }
449
450
 
450
451
  if (dryRun) {
451
- console.log("\n[DRY RUN] Would write reflection report + update opinions");
452
+ emit.data("[DRY RUN] Would write reflection report + update opinions");
452
453
  } else {
453
454
  const report = formatReport(period, notes, ratings, opinionChanges);
454
455
  const filepath = writeReport(report, period);
455
456
  setLastReflectDate(new Date().toISOString().slice(0, 10));
456
- console.log(`\nCreated reflection report: ${filepath}`);
457
+ emit.ok(`\nCreated reflection report: ${filepath}`);
457
458
 
458
459
  const opinions = readOpinions();
459
460
  const high = opinions.filter((o) => o.confidence >= 0.85);
460
461
  if (high.length > 0) {
461
- console.log("\nHigh-confidence opinions (injected into context):");
462
+ emit.ok("\nHigh-confidence opinions (injected into context):");
462
463
  for (const o of high) {
463
- console.log(` [${Math.round(o.confidence * 100)}%] ${o.statement.slice(0, 80)}`);
464
+ emit.ok(` [${Math.round(o.confidence * 100)}%] ${o.statement.slice(0, 80)}`);
464
465
  }
465
466
  }
466
467
  }
@@ -8,12 +8,14 @@
8
8
  * Invoked via `pal cli usage [--today|--week|--month|--all] [--project <name>]`.
9
9
  */
10
10
 
11
+ import { spawnSync } from "node:child_process";
11
12
  import { existsSync, readdirSync, readFileSync } from "node:fs";
12
13
  import { homedir } from "node:os";
13
14
  import { resolve } from "node:path";
14
15
  import { parseArgs } from "node:util";
15
16
  import { MODEL_PRICING } from "../hooks/lib/models";
16
17
  import { palHome } from "../hooks/lib/paths";
18
+ import { findBinaryOnPath } from "../hooks/lib/which";
17
19
 
18
20
  // ── Types ──
19
21
 
@@ -370,6 +372,51 @@ function readPalInference(): {
370
372
  return { buckets, byModel, byCaller };
371
373
  }
372
374
 
375
+ // ── rtk compression savings ──
376
+
377
+ interface RtkSummary {
378
+ total_commands: number;
379
+ total_saved: number;
380
+ avg_savings_pct: number;
381
+ }
382
+
383
+ /**
384
+ * Query rtk's own savings ledger. `installed: false` means rtk isn't on PATH;
385
+ * `summary: null` with `installed: true` means rtk is present but has no data
386
+ * (or errored) — the two cases print differently in the usage report.
387
+ */
388
+ function readRtkGain(): { installed: boolean; summary: RtkSummary | null } {
389
+ const rtk = findBinaryOnPath("rtk");
390
+ if (!rtk) return { installed: false, summary: null };
391
+ try {
392
+ const r = spawnSync(rtk, ["gain", "--format", "json"], {
393
+ encoding: "utf8",
394
+ stdio: ["ignore", "pipe", "ignore"],
395
+ });
396
+ if (r.status !== 0 || !r.stdout) return { installed: true, summary: null };
397
+ const parsed = JSON.parse(r.stdout) as { summary?: RtkSummary };
398
+ return { installed: true, summary: parsed.summary ?? null };
399
+ } catch {
400
+ return { installed: true, summary: null };
401
+ }
402
+ }
403
+
404
+ function printRtkGain(): void {
405
+ const { installed, summary } = readRtkGain();
406
+ console.log("\n rtk Compression\n");
407
+ if (!installed) {
408
+ console.log(" rtk not installed");
409
+ return;
410
+ }
411
+ if (!summary || summary.total_commands === 0) {
412
+ console.log(" rtk installed — no savings recorded yet");
413
+ return;
414
+ }
415
+ console.log(
416
+ ` Tokens saved ${fmt(summary.total_saved).padStart(8)} tok ${summary.avg_savings_pct.toFixed(1)}% avg across ${fmt(summary.total_commands)} commands`
417
+ );
418
+ }
419
+
373
420
  // ── CLI ──
374
421
 
375
422
  export function usage() {
@@ -426,6 +473,8 @@ export function usage() {
426
473
  printRow("Total", tb.total);
427
474
  }
428
475
 
476
+ printRtkGain();
477
+
429
478
  const grand = emptyBucket();
430
479
  for (const b of [cc.buckets.total, pal.buckets.total]) {
431
480
  grand.input += b.input;