portable-agent-layer 0.61.3 → 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.
Files changed (34) hide show
  1. package/assets/schema/pal-settings.schema.json +111 -0
  2. package/assets/skills/opinion/SKILL.md +7 -7
  3. package/assets/skills/opinion/tools/opinion.ts +6 -4
  4. package/assets/templates/hooks.copilot.json +4 -0
  5. package/assets/templates/hooks.cursor.json +4 -0
  6. package/assets/templates/pal-settings.json +8 -3
  7. package/assets/templates/settings.claude.json +9 -2
  8. package/package.json +1 -1
  9. package/src/cli/index.ts +16 -1
  10. package/src/hooks/RtkWrap.ts +52 -0
  11. package/src/hooks/UserPromptOrchestrator.ts +3 -3
  12. package/src/hooks/handlers/failure-principle.ts +1 -1
  13. package/src/hooks/handlers/inject-retrieval.ts +18 -6
  14. package/src/hooks/handlers/rating.ts +1 -1
  15. package/src/hooks/handlers/session-intelligence.ts +1 -1
  16. package/src/hooks/handlers/session-name.ts +1 -1
  17. package/src/hooks/handlers/update-check.ts +8 -5
  18. package/src/hooks/lib/inference.ts +2 -41
  19. package/src/hooks/lib/opinions.ts +15 -4
  20. package/src/hooks/lib/settings.ts +5 -0
  21. package/src/hooks/lib/steering.ts +162 -0
  22. package/src/hooks/lib/text-similarity.ts +18 -0
  23. package/src/hooks/lib/which.ts +41 -0
  24. package/src/targets/lib.ts +18 -1
  25. package/src/targets/opencode/plugin.ts +35 -4
  26. package/src/tools/agent/algorithm-reflect.ts +2 -1
  27. package/src/tools/agent/handoff-note.ts +3 -2
  28. package/src/tools/agent/project.ts +42 -2
  29. package/src/tools/agent/relationship-note.ts +6 -11
  30. package/src/tools/agent/thread.ts +6 -9
  31. package/src/tools/agent/wisdom-frame.ts +2 -1
  32. package/src/tools/lib/emit.ts +31 -0
  33. package/src/tools/relationship-reflect.ts +14 -13
  34. package/src/tools/token-cost.ts +49 -0
@@ -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
+ }
@@ -155,3 +155,21 @@ export function similarity(a: string, b: string): number {
155
155
 
156
156
  return (2 * intersection) / (ka.size + kb.size);
157
157
  }
158
+
159
+ /**
160
+ * Asymmetric containment: fraction of `query` keywords also present in `target`.
161
+ * Unlike Dice, it is not penalized by `target` being much longer, so a short
162
+ * keyword query can still match a full-sentence statement.
163
+ */
164
+ export function containment(query: string, target: string): number {
165
+ const q = extractKeywords(query);
166
+ const t = extractKeywords(target);
167
+ if (q.size === 0 || t.size === 0) return 0;
168
+
169
+ let intersection = 0;
170
+ for (const w of q) {
171
+ if (t.has(w)) intersection++;
172
+ }
173
+
174
+ return intersection / q.size;
175
+ }
@@ -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
+ }
@@ -166,7 +166,7 @@ export function mergeSettings(existing: Settings, template: Settings): Settings
166
166
  }
167
167
  }
168
168
 
169
- // Merge permissions.allow (deduplicate)
169
+ // Merge permissions.allow (deduplicate), then drop deprecated entries.
170
170
  if (template.permissions?.allow) {
171
171
  result.permissions ??= {};
172
172
  result.permissions.allow ??= [];
@@ -176,6 +176,14 @@ export function mergeSettings(existing: Settings, template: Settings): Settings
176
176
  }
177
177
  }
178
178
  }
179
+ // Strip ineffective Grep(...)/Glob(...) rules left by older templates. Claude Code
180
+ // governs Grep/Glob via Read(...) rules, so a path-scoped Grep()/Glob() entry never
181
+ // matches and Claude Code warns about it on every prompt. Read(//*) already covers them.
182
+ if (result.permissions?.allow) {
183
+ result.permissions.allow = result.permissions.allow.filter(
184
+ (perm) => !isIneffectiveFileToolRule(perm)
185
+ );
186
+ }
179
187
 
180
188
  // Merge skillOverrides (object with skill name keys, add if not present)
181
189
  if (template.skillOverrides && typeof template.skillOverrides === "object") {
@@ -455,6 +463,15 @@ function canonicalPalCmd(cmd: string): string {
455
463
  return withoutEnv;
456
464
  }
457
465
 
466
+ /**
467
+ * True for path-scoped Grep()/Glob() allow rules, which Claude Code cannot honor —
468
+ * it resolves Grep/Glob permission through Read(...) rules, so these never match and
469
+ * trigger a warning on every prompt. Bare "Grep"/"Glob" tool allows are left intact.
470
+ */
471
+ function isIneffectiveFileToolRule(perm: string): boolean {
472
+ return /^(?:Grep|Glob)\(/.test(perm);
473
+ }
474
+
458
475
  export function loadCodexHooksTemplate(
459
476
  templatePath: string,
460
477
  pkgRoot: string
@@ -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
+ };