portable-agent-layer 0.61.4 → 0.62.1

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,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
  }
@@ -9,7 +9,7 @@ import { existsSync, readdirSync, readFileSync } from "node:fs";
9
9
  import { homedir } from "node:os";
10
10
  import { resolve } from "node:path";
11
11
  import { parseArgs } from "node:util";
12
- import { MODEL_PRICING } from "../hooks/lib/models";
12
+ import { costOfUsage } from "../hooks/lib/models";
13
13
 
14
14
  // ── Types ──
15
15
 
@@ -142,16 +142,13 @@ function parseSession(filepath: string, sessionId: string): Usage {
142
142
  : (u.cache_creation_input_tokens ?? 0);
143
143
  const cacheWrite1h = cw1h ?? 0;
144
144
 
145
- const p = MODEL_PRICING[model];
146
- if (p) {
147
- usage.cost +=
148
- (input * p.input +
149
- output * p.output +
150
- cacheWrite5m * p.cacheWrite5m +
151
- cacheWrite1h * p.cacheWrite1h +
152
- cr * p.cacheRead) /
153
- 1_000_000;
154
- }
145
+ usage.cost += costOfUsage(model, {
146
+ input,
147
+ output,
148
+ cacheWrite5m,
149
+ cacheWrite1h,
150
+ cacheRead: cr,
151
+ });
155
152
 
156
153
  usage.input += input;
157
154
  usage.output += output;