portable-agent-layer 0.112.0 → 0.114.0-canary.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/README.md +1 -1
- package/assets/schema/pal-settings.schema.json +4 -0
- package/assets/templates/pal-settings.json +1 -0
- package/package.json +4 -2
- package/src/cli/index.ts +13 -5
- package/src/cli/tree.ts +11 -1
- package/src/cli/update-flow.ts +15 -0
- package/src/cli/whats-new.ts +14 -8
- package/src/hooks/StopOrchestrator.ts +1 -1
- package/src/hooks/handlers/update-check.ts +14 -3
- package/src/hooks/lib/claim-check.ts +8 -0
- package/src/hooks/lib/decision-label.ts +70 -0
- package/src/hooks/lib/models.ts +5 -1
- package/src/hooks/lib/work-tree-gate.ts +48 -4
package/README.md
CHANGED
|
@@ -77,7 +77,7 @@ pal cli status # check your setup
|
|
|
77
77
|
| `pal cli init` | Scaffold PAL home directory and install hooks |
|
|
78
78
|
| `pal cli install` | Register hooks/skills for targets |
|
|
79
79
|
| `pal cli uninstall` | Remove hooks/skills for targets |
|
|
80
|
-
| `pal cli update` | Update PAL (git pull or npm update) and reinstall hooks. Install asks once whether to do this on its own: when a session closes PAL checks for a release (at most hourly) and installs it, so opening PAL is never slowed. A failed update waits a day before retrying. The switch lives in the control room under Settings → Updates. On a repo install an update waits while the clone has uncommitted changes |
|
|
80
|
+
| `pal cli update` | Update PAL (git pull or npm update) and reinstall hooks. Install asks once whether to do this on its own: when a session closes PAL checks for a release (at most hourly) and installs it, so opening PAL is never slowed. A failed update waits a day before retrying. The switch lives in the control room under Settings → Updates. On a repo install an update waits while the clone has uncommitted changes. `--canary` installs the latest prerelease from the `canary` branch instead; auto-update always follows the stable release |
|
|
81
81
|
| `pal cli export` | Export user state (telos, memory) to a zip |
|
|
82
82
|
| `pal cli import` | Import user state from a zip |
|
|
83
83
|
| `pal cli status` | Show current PAL configuration |
|
|
@@ -82,6 +82,10 @@
|
|
|
82
82
|
"type": "boolean",
|
|
83
83
|
"description": "Opt-in, off by default until 1.0. Send a turn back when it committed, made a branch, opened a PR or published an artifact without recording it in the work tree (pal cli work). Needs workTree."
|
|
84
84
|
},
|
|
85
|
+
"workTreeDecisionGate": {
|
|
86
|
+
"type": "boolean",
|
|
87
|
+
"description": "Opt-in, off by default until 1.0. Send a turn back when a small-model check finds it settled a decision that no pal cli work decide recorded. One small-model call at the end of every turn. Needs workTree."
|
|
88
|
+
},
|
|
85
89
|
"dueReminders": {
|
|
86
90
|
"type": "boolean",
|
|
87
91
|
"description": "Opt-in, off by default until 1.0. Due maintenance reminders (learning analysis, relationship reflect) with every prompt until a reply names their command; then quiet until the next day."
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "portable-agent-layer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.114.0-canary.1",
|
|
4
4
|
"description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -74,7 +74,9 @@
|
|
|
74
74
|
"eval:correction-kind": "bun eval/correction-kind/build-prompt.ts && bun eval/run.ts correction-kind",
|
|
75
75
|
"eval:correction-kind:test": "bun eval/correction-kind/test-set.ts",
|
|
76
76
|
"eval:rule-drafter": "bun eval/rule-drafter/build-prompt.ts && bun eval/run.ts rule-drafter",
|
|
77
|
-
"eval:rule-drafter:test": "bun eval/rule-drafter/test-set.ts"
|
|
77
|
+
"eval:rule-drafter:test": "bun eval/rule-drafter/test-set.ts",
|
|
78
|
+
"eval:decision": "bun eval/decision/score.ts validation",
|
|
79
|
+
"eval:decision:test": "bun eval/decision/score.ts test"
|
|
78
80
|
},
|
|
79
81
|
"lint-staged": {
|
|
80
82
|
"*.ts": [
|
package/src/cli/index.ts
CHANGED
|
@@ -49,7 +49,7 @@ const tree = cliTree({
|
|
|
49
49
|
process.exit(await install(resolveTargets(argv), argv));
|
|
50
50
|
},
|
|
51
51
|
uninstall: ({ argv }) => uninstall(argv),
|
|
52
|
-
update: () => update(),
|
|
52
|
+
update: ({ values }) => update(values.canary === true),
|
|
53
53
|
export: ({ argv }) => exportState(argv),
|
|
54
54
|
import: ({ argv }) => importState(argv),
|
|
55
55
|
status: () => status(),
|
|
@@ -553,15 +553,23 @@ async function importState(args: string[]) {
|
|
|
553
553
|
log.info("Run 'pal cli install' to re-register hooks.");
|
|
554
554
|
}
|
|
555
555
|
|
|
556
|
-
async function update() {
|
|
557
|
-
const { checkForUpdate, clearUpdateCache } = await import(
|
|
556
|
+
async function update(canary: boolean) {
|
|
557
|
+
const { checkCanary, checkForUpdate, clearUpdateCache, isRepoMode } = await import(
|
|
558
558
|
"../hooks/handlers/update-check"
|
|
559
559
|
);
|
|
560
560
|
const { reinstallInFreshProcess } = await import("./reinstall");
|
|
561
|
-
const { runUpdate } = await import("./update-flow");
|
|
561
|
+
const { CANARY_NEEDS_PACKAGE, runUpdate, updateCheck } = await import("./update-flow");
|
|
562
|
+
const check = updateCheck(canary, isRepoMode(), {
|
|
563
|
+
stable: () => checkForUpdate(true),
|
|
564
|
+
canary: checkCanary,
|
|
565
|
+
});
|
|
566
|
+
if (!check) {
|
|
567
|
+
log.error(CANARY_NEEDS_PACKAGE);
|
|
568
|
+
process.exit(1);
|
|
569
|
+
}
|
|
562
570
|
process.exit(
|
|
563
571
|
await runUpdate({
|
|
564
|
-
check
|
|
572
|
+
check,
|
|
565
573
|
reinstall: (env) => {
|
|
566
574
|
clearUpdateCache();
|
|
567
575
|
return reinstallInFreshProcess(process.argv[1], env);
|
package/src/cli/tree.ts
CHANGED
|
@@ -109,7 +109,17 @@ function adminCommands(admin: AdminHandlers): Record<string, Leaf | Group> {
|
|
|
109
109
|
options: agentOptions("Uninstall from"),
|
|
110
110
|
run: admin.uninstall,
|
|
111
111
|
}),
|
|
112
|
-
update: leaf({
|
|
112
|
+
update: leaf({
|
|
113
|
+
summary: "Update PAL (git pull or bun add -g)",
|
|
114
|
+
options: {
|
|
115
|
+
canary: {
|
|
116
|
+
type: "boolean",
|
|
117
|
+
description:
|
|
118
|
+
"Install the latest canary prerelease instead of the stable release",
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
run: admin.update,
|
|
122
|
+
}),
|
|
113
123
|
export: leaf({
|
|
114
124
|
summary: "Export your state to a zip",
|
|
115
125
|
args: "[path]",
|
package/src/cli/update-flow.ts
CHANGED
|
@@ -33,6 +33,21 @@ export interface UpdateSteps {
|
|
|
33
33
|
run?: Run;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
export const CANARY_NEEDS_PACKAGE =
|
|
37
|
+
"--canary installs from npm. A repo install follows its git checkout: check out the canary branch there instead.";
|
|
38
|
+
|
|
39
|
+
type Check = UpdateSteps["check"];
|
|
40
|
+
|
|
41
|
+
/** Null when --canary is asked of a repo install, which has no npm channel to follow. */
|
|
42
|
+
export function updateCheck(
|
|
43
|
+
canary: boolean,
|
|
44
|
+
repoMode: boolean,
|
|
45
|
+
checks: { stable: Check; canary: Check }
|
|
46
|
+
): Check | null {
|
|
47
|
+
if (!canary) return checks.stable;
|
|
48
|
+
return repoMode ? null : checks.canary;
|
|
49
|
+
}
|
|
50
|
+
|
|
36
51
|
/** `bun update -g` keeps the caret range, and on 0.x a caret allows only patch bumps. */
|
|
37
52
|
export function downloadCommand(
|
|
38
53
|
update: UpdateCache
|
package/src/cli/whats-new.ts
CHANGED
|
@@ -15,16 +15,22 @@ export interface WhatsNew {
|
|
|
15
15
|
|
|
16
16
|
const SHOWN = 3;
|
|
17
17
|
|
|
18
|
-
function
|
|
19
|
-
return version.replace(/^v/, "")
|
|
18
|
+
function bare(version: string): string {
|
|
19
|
+
return version.replace(/^v/, "");
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
export function newer(a: string, b: string): boolean {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
return
|
|
23
|
+
return Bun.semver.order(bare(a), bare(b)) === 1;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isPrerelease(version: string): boolean {
|
|
27
|
+
return bare(version).includes("-");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A stable release's notes already cover the canaries before it, so a stable update skips them. */
|
|
31
|
+
function inRange(tag: string, from: string, to: string): boolean {
|
|
32
|
+
if (isPrerelease(tag) && !isPrerelease(to)) return false;
|
|
33
|
+
return newer(tag, from) && !newer(tag, to);
|
|
28
34
|
}
|
|
29
35
|
|
|
30
36
|
/** "* **rating:** confirm corrections ([abc](url))" reads "Confirm corrections". */
|
|
@@ -71,7 +77,7 @@ export function summarizeReleases(
|
|
|
71
77
|
slug: string
|
|
72
78
|
): WhatsNew | null {
|
|
73
79
|
const items = releases
|
|
74
|
-
.filter((r) =>
|
|
80
|
+
.filter((r) => inRange(r.tag_name, from, to))
|
|
75
81
|
.flatMap((r) => releaseNotes(r.body ?? ""));
|
|
76
82
|
if (items.length === 0) return null;
|
|
77
83
|
return {
|
|
@@ -139,12 +139,18 @@ export function isNewerRelease(latest: string, current: string): boolean {
|
|
|
139
139
|
return Bun.semver.order(latest, current) === 1;
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
-
|
|
142
|
+
/** latest is the stable release from main; canary is the prerelease from the canary branch. */
|
|
143
|
+
export type Channel = "latest" | "canary";
|
|
144
|
+
|
|
145
|
+
export async function checkNpm(
|
|
146
|
+
channel: Channel = "latest",
|
|
147
|
+
fetchImpl: typeof fetch = fetch
|
|
148
|
+
): Promise<UpdateCache> {
|
|
143
149
|
const current = getInstalledVersion();
|
|
144
150
|
|
|
145
151
|
try {
|
|
146
|
-
const response = await
|
|
147
|
-
|
|
152
|
+
const response = await fetchImpl(
|
|
153
|
+
`https://registry.npmjs.org/portable-agent-layer/${channel}`,
|
|
148
154
|
{ signal: AbortSignal.timeout(5000) }
|
|
149
155
|
);
|
|
150
156
|
|
|
@@ -170,6 +176,11 @@ async function checkNpm(): Promise<UpdateCache> {
|
|
|
170
176
|
}
|
|
171
177
|
}
|
|
172
178
|
|
|
179
|
+
/** The canary check is never cached: the cache feeds the stable update notice. */
|
|
180
|
+
export function checkCanary(fetchImpl: typeof fetch = fetch): Promise<UpdateCache> {
|
|
181
|
+
return checkNpm("canary", fetchImpl);
|
|
182
|
+
}
|
|
183
|
+
|
|
173
184
|
/** Run update check. Caches result. Use force=true to skip cache (e.g. for `pal cli update`). */
|
|
174
185
|
export async function checkForUpdate(force = false): Promise<UpdateCache> {
|
|
175
186
|
if (!force) {
|
|
@@ -137,6 +137,14 @@ function isUserPrompt(entry: TranscriptLine): boolean {
|
|
|
137
137
|
return text !== null && !isSystemText(text) && !isCodexInjectedContext(text);
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
+
export function promptThisTurn(lines: string[]): string | null {
|
|
141
|
+
const prompts = lines
|
|
142
|
+
.map(parsed)
|
|
143
|
+
.filter((e): e is TranscriptLine => e !== null && isUserPrompt(e));
|
|
144
|
+
const last = prompts.at(-1);
|
|
145
|
+
return last ? promptText(last) : null;
|
|
146
|
+
}
|
|
147
|
+
|
|
140
148
|
const ran = (input: unknown) => JSON.stringify(input ?? {});
|
|
141
149
|
|
|
142
150
|
function callsIn(entry: TranscriptLine, tools: ReadonlySet<string>): string[] {
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a turn settled a decision. Code can see that a turn committed or
|
|
3
|
+
* opened a PR, but a decision is only words, so the work-tree gate asks the
|
|
4
|
+
* small model and sends the turn back when nothing was recorded.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface DecisionLabel {
|
|
8
|
+
decision: boolean;
|
|
9
|
+
what: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const DECISION_SYSTEM = `You read one turn of a working session between a user and an AI assistant: the user's message and the end of the assistant's reply. Say whether the turn settled a decision.
|
|
13
|
+
|
|
14
|
+
A decision is a choice between alternatives about the work that holds beyond this turn: a policy, a design, a direction, a scope (what is in, out, parked or dropped), or a standing way of working. Examples: "make them opt in for now", "we only track new posts, not past ones", "we don't need the predictor", "PRs go to canary from now on".
|
|
15
|
+
|
|
16
|
+
A decision is often a few words that answer something open earlier: picking one of the options the assistant offered, keeping something where it is, saying something is not needed or should wait, changing how a thing behaves. Read the reply to see what the user's words settled; a short message can still be a decision.
|
|
17
|
+
|
|
18
|
+
These are not decisions:
|
|
19
|
+
- a go-ahead or approval of a plan or of the assistant's proposals as they stand ("go", "yes pls", "build all of them", "ok to both, build it"), unless it also picks between alternatives or changes the plan;
|
|
20
|
+
- where the work goes (which branch or PR) and other bookkeeping the assistant handles;
|
|
21
|
+
- a plain request for work ("fix CI", "push and open a PR", "plan it", "list our tasks"), including which step comes first;
|
|
22
|
+
- a question, even one about what to decide or what was decided before;
|
|
23
|
+
- a fact, a status report, or an idea floated for discussion;
|
|
24
|
+
- a recommendation or proposal from the assistant that the user has not accepted;
|
|
25
|
+
- small implementation choices the assistant makes while working.
|
|
26
|
+
|
|
27
|
+
Most turns settle no decision. When unsure, answer false.
|
|
28
|
+
|
|
29
|
+
Set what to the decision in under fifteen words, or to an empty string when there is none.`;
|
|
30
|
+
|
|
31
|
+
const DECISION_SCHEMA = {
|
|
32
|
+
type: "object",
|
|
33
|
+
properties: {
|
|
34
|
+
decision: { type: "boolean" },
|
|
35
|
+
what: { type: "string" },
|
|
36
|
+
},
|
|
37
|
+
required: ["decision", "what"],
|
|
38
|
+
additionalProperties: false,
|
|
39
|
+
} as const;
|
|
40
|
+
|
|
41
|
+
const MESSAGE_MAX = 1500;
|
|
42
|
+
const REPLY_END_MAX = 1500;
|
|
43
|
+
|
|
44
|
+
export function decisionUser(message: string, reply: string) {
|
|
45
|
+
return `The user's message:\n<message>\n${message.slice(0, MESSAGE_MAX)}\n</message>\n\nThe end of the assistant's reply:\n<reply_end>\n${reply.slice(-REPLY_END_MAX)}\n</reply_end>`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function decisionRequest(message: string, reply: string, sessionId?: string) {
|
|
49
|
+
return {
|
|
50
|
+
system: DECISION_SYSTEM,
|
|
51
|
+
user: decisionUser(message, reply),
|
|
52
|
+
maxTokens: 100,
|
|
53
|
+
timeout: 60_000,
|
|
54
|
+
jsonSchema: DECISION_SCHEMA,
|
|
55
|
+
caller: "decision-label",
|
|
56
|
+
sessionId,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function parseDecision(output: string | null | undefined): DecisionLabel | null {
|
|
61
|
+
if (!output) return null;
|
|
62
|
+
try {
|
|
63
|
+
const parsed = JSON.parse(output) as Record<string, unknown>;
|
|
64
|
+
if (typeof parsed.decision !== "boolean") return null;
|
|
65
|
+
const what = typeof parsed.what === "string" ? parsed.what.trim() : "";
|
|
66
|
+
return { decision: parsed.decision, what: parsed.decision ? what : "" };
|
|
67
|
+
} catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
package/src/hooks/lib/models.ts
CHANGED
|
@@ -76,7 +76,11 @@ export function isFixedModelRoute(route: string): route is FixedModelRoute {
|
|
|
76
76
|
* caller, whose eval is iterated on 5.5 until it passes. The rest stay on
|
|
77
77
|
* HAIKU_MODEL until theirs is evaluated.
|
|
78
78
|
*/
|
|
79
|
-
const ANTHROPIC_ON_HAIKU_5_5 = new Set([
|
|
79
|
+
const ANTHROPIC_ON_HAIKU_5_5 = new Set([
|
|
80
|
+
"rule-drafter",
|
|
81
|
+
"correction-kind",
|
|
82
|
+
"decision-label",
|
|
83
|
+
]);
|
|
80
84
|
|
|
81
85
|
function evaluatedModel(
|
|
82
86
|
models: RouteModels,
|
|
@@ -4,8 +4,11 @@
|
|
|
4
4
|
* nothing in the tree is sent back once to record it.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { callsThisTurn, commandsThisTurn } from "./claim-check";
|
|
7
|
+
import { callsThisTurn, commandsThisTurn, promptThisTurn } from "./claim-check";
|
|
8
8
|
import { alreadySentBack, transcriptLines } from "./claim-log";
|
|
9
|
+
import { type DecisionLabel, decisionRequest, parseDecision } from "./decision-label";
|
|
10
|
+
import { hookFinalReply } from "./hook-turn";
|
|
11
|
+
import { inference } from "./inference";
|
|
9
12
|
import { sessionDir } from "./session-dir";
|
|
10
13
|
import { isOptedIn } from "./settings";
|
|
11
14
|
import type { StopTurnPayload } from "./stop";
|
|
@@ -14,6 +17,7 @@ import { attachGitProgress } from "./work-tree-git";
|
|
|
14
17
|
import { renderPromptLine } from "./work-tree-render";
|
|
15
18
|
|
|
16
19
|
const RECORDS_WORK_RE = /\bpal\s+cli\s+work\b/;
|
|
20
|
+
const RECORDS_DECISION_RE = /\bpal\s+cli\s+work\s+decide\b/;
|
|
17
21
|
|
|
18
22
|
const PRODUCERS: { what: string; re: RegExp }[] = [
|
|
19
23
|
{ what: "committed", re: /\bgit\s+commit\b/ },
|
|
@@ -34,8 +38,12 @@ function producedThisTurn(commands: string[], lines: string[]): string[] {
|
|
|
34
38
|
return produced;
|
|
35
39
|
}
|
|
36
40
|
|
|
41
|
+
function whereWorkStands(scope: string): string {
|
|
42
|
+
return renderPromptLine(readTree(scope), false) ?? "Work: nothing recorded yet";
|
|
43
|
+
}
|
|
44
|
+
|
|
37
45
|
function sendBackReason(produced: string[], scope: string): string {
|
|
38
|
-
const where =
|
|
46
|
+
const where = whereWorkStands(scope);
|
|
39
47
|
return [
|
|
40
48
|
`This turn ${produced.join(", ")} but recorded nothing in the work tree (${where}).`,
|
|
41
49
|
'Record where the work stands with `pal cli work stopped "…"`, plus `decide` or `attach` where they fit;',
|
|
@@ -56,11 +64,47 @@ export function workTreeSendBack(
|
|
|
56
64
|
return produced.length > 0 ? sendBackReason(produced, scopeOf(cwd)) : null;
|
|
57
65
|
}
|
|
58
66
|
|
|
67
|
+
type LabelDecision = (message: string, reply: string) => Promise<DecisionLabel | null>;
|
|
68
|
+
|
|
69
|
+
async function labelWithInference(message: string, reply: string) {
|
|
70
|
+
const result = await inference(decisionRequest(message, reply));
|
|
71
|
+
return result.success ? parseDecision(result.output) : null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function decisionReason(what: string, scope: string): string {
|
|
75
|
+
return [
|
|
76
|
+
`This turn settled a decision (${what}) but recorded none in the work tree (${whereWorkStands(scope)}).`,
|
|
77
|
+
'Record it with `pal cli work decide "…"`; if no node fits, open one first with `pal cli work open "…"`.',
|
|
78
|
+
].join(" ");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function decisionSendBack(
|
|
82
|
+
payload: StopTurnPayload,
|
|
83
|
+
lines: string[],
|
|
84
|
+
cwd: string,
|
|
85
|
+
label: LabelDecision = labelWithInference
|
|
86
|
+
): Promise<string | null> {
|
|
87
|
+
if (!isOptedIn("workTree") || !isOptedIn("workTreeDecisionGate")) return null;
|
|
88
|
+
if (alreadySentBack(payload)) return null;
|
|
89
|
+
const commands = commandsThisTurn(lines);
|
|
90
|
+
if (commands === null || commands.some((c) => RECORDS_DECISION_RE.test(c))) return null;
|
|
91
|
+
const message = promptThisTurn(lines);
|
|
92
|
+
const reply = hookFinalReply(payload);
|
|
93
|
+
if (!message || !reply) return null;
|
|
94
|
+
const decided = await label(message, reply);
|
|
95
|
+
return decided?.decision ? decisionReason(decided.what, scopeOf(cwd)) : null;
|
|
96
|
+
}
|
|
97
|
+
|
|
59
98
|
/** The reason to send the turn back with, or null after attaching what git moved. */
|
|
60
|
-
export function stopWorkTree(
|
|
99
|
+
export async function stopWorkTree(
|
|
100
|
+
payload: StopTurnPayload | null
|
|
101
|
+
): Promise<string | null> {
|
|
61
102
|
if (!payload || !isOptedIn("workTree")) return null;
|
|
62
103
|
const cwd = sessionDir();
|
|
63
|
-
const
|
|
104
|
+
const lines = transcriptLines(payload);
|
|
105
|
+
const sendBack =
|
|
106
|
+
workTreeSendBack(payload, lines, cwd) ??
|
|
107
|
+
(await decisionSendBack(payload, lines, cwd));
|
|
64
108
|
if (sendBack) return sendBack;
|
|
65
109
|
attachGitProgress(cwd);
|
|
66
110
|
return null;
|