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.
- package/assets/schema/pal-settings.schema.json +111 -0
- package/assets/templates/hooks.copilot.json +4 -0
- package/assets/templates/hooks.cursor.json +4 -0
- package/assets/templates/pal-settings.json +8 -3
- package/assets/templates/settings.claude.json +23 -0
- package/package.json +1 -1
- package/src/cli/index.ts +16 -1
- package/src/hooks/RtkWrap.ts +52 -0
- package/src/hooks/UserPromptOrchestrator.ts +3 -3
- package/src/hooks/handlers/inject-retrieval.ts +18 -6
- package/src/hooks/lib/inference.ts +2 -41
- package/src/hooks/lib/models.ts +65 -10
- package/src/hooks/lib/settings.ts +5 -0
- package/src/hooks/lib/steering.ts +162 -0
- package/src/hooks/lib/which.ts +41 -0
- package/src/targets/opencode/plugin.ts +35 -4
- package/src/tools/agent/algorithm-reflect.ts +2 -1
- package/src/tools/agent/handoff-note.ts +3 -2
- package/src/tools/agent/project.ts +42 -2
- package/src/tools/agent/relationship-note.ts +6 -11
- package/src/tools/agent/thread.ts +6 -9
- package/src/tools/agent/wisdom-frame.ts +2 -1
- package/src/tools/lib/emit.ts +31 -0
- package/src/tools/relationship-reflect.ts +14 -13
- package/src/tools/session-summary.ts +8 -11
- package/src/tools/token-cost.ts +53 -33
|
@@ -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
|
-
|
|
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 (
|
|
176
|
+
if (injectedText) {
|
|
151
177
|
const injected = {
|
|
152
|
-
id: `pal-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 "
|
|
10
|
-
* bun ~/.pal/tools/relationship-note.ts --w "
|
|
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 "
|
|
40
|
-
bun ~/.pal/tools/relationship-note.ts --w "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
417
|
-
|
|
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
|
-
|
|
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
|
-
|
|
429
|
+
emit.ok(`\nAverage Rating: ${avgRating.toFixed(1)}/10`);
|
|
429
430
|
|
|
430
431
|
const summaries = groupNoteOccurrences(notes);
|
|
431
|
-
|
|
432
|
+
emit.ok(`Observations: ${summaries.length} unique`);
|
|
432
433
|
|
|
433
434
|
if (opinionChanges.length > 0) {
|
|
434
|
-
|
|
435
|
+
emit.ok("\nOpinion changes:");
|
|
435
436
|
for (const change of opinionChanges) {
|
|
436
437
|
if (change.action === "created") {
|
|
437
|
-
|
|
438
|
+
emit.ok(
|
|
438
439
|
` + NEW (${Math.round(change.newConfidence * 100)}%) ${change.statement.slice(0, 80)}`
|
|
439
440
|
);
|
|
440
441
|
} else {
|
|
441
|
-
|
|
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
|
-
|
|
448
|
+
emit.ok("\nNo opinion changes");
|
|
448
449
|
}
|
|
449
450
|
|
|
450
451
|
if (dryRun) {
|
|
451
|
-
|
|
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
|
-
|
|
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
|
-
|
|
462
|
+
emit.ok("\nHigh-confidence opinions (injected into context):");
|
|
462
463
|
for (const o of high) {
|
|
463
|
-
|
|
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 {
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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;
|