portable-agent-layer 0.111.0 → 0.113.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.
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 |
@@ -88,6 +88,7 @@ pal cli status # check your setup
88
88
  | `pal cli cost` | What each month was paid against its API-equivalent cost, per agent |
89
89
  | `pal cli wrapped [--year <yyyy>]` | A year of your work in numbers, read from the event log |
90
90
  | `pal cli patterns` | How you work across projects, read from the event log |
91
+ | `pal cli work [show\|list\|open\|enter\|park\|wait\|done\|stopped\|decide\|attach\|follow-up]` | The work tree: what you are on in this project, where it stopped, what is parked or waiting, and the decisions and artifacts on each node. Detours are child nodes and `done` returns to the parent. With `dynamicContext.workTree` opted into, the active node rides along in every session and prompt, and your own commits attach to it at turn end |
91
92
  | `pal cli content preserve` | Copy every Claude Code transcript, gzipped, into `memory/content/` before Claude Code deletes it (30 days by default). Runs on its own whenever a session closes; the content store never leaves the machine, so `export` skips it |
92
93
  | `pal cli events discord-nag <sent\|answered>` | For a bot outside PAL: record a nag it sent (`--kind <kind>`), or the answer to one and how long it took (`--after-sec <n>`), in the local event log |
93
94
  | `pal cli infer` | One model call for scripts and bots, on the active agent's route: prompt on stdin, reply on stdout. `--tier small\|medium\|large\|flagship` (on Claude: Haiku, Sonnet, Opus, Fable; on Codex: Luna, Sol, Sol at high effort, Astra; on Antigravity: Flash at low, medium and high effort, and no flagship; an agent without the tier asked refuses it), `--system <file>`, `--schema <file>` for a JSON reply checked against it, `--timeout <seconds>`. The call loads no PAL hooks or context; exits 1 with the reason when no route answers |
@@ -74,6 +74,14 @@
74
74
  "type": "boolean",
75
75
  "description": "One line with every prompt saying whether the work in the current directory is committed, pushed and released; a registered project without git is named as such."
76
76
  },
77
+ "workTree": {
78
+ "type": "boolean",
79
+ "description": "Opt-in, off by default until 1.0. The work tree (pal cli work): the active node, the detours it sits inside, parked work and what waits on the user at session start and after compaction, one line of it with every prompt, and new commits and branches attached to the active node at the end of a turn."
80
+ },
81
+ "workTreeGate": {
82
+ "type": "boolean",
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
+ },
77
85
  "dueReminders": {
78
86
  "type": "boolean",
79
87
  "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."
@@ -56,6 +56,7 @@ Start your response with the following header in this mode:
56
56
  - **Mandatory output format** — Every response MUST use exactly one of the output formats above (ALGORITHM, NATIVE, or MINIMAL). No freeform output.
57
57
  - **Response format before questions** — Always complete the current response format output FIRST, then invoke AskUserQuestion at the end.
58
58
  - **Named project → resume first** — Whenever the user references a project by name in ANY mode, run `pal cli project resume <slug>` before any file search or task execution. The project record has paths, context, and handoff notes that make manual searching unnecessary.
59
+ {{IF workTree}}- **Keep the work tree true** — Record work in the tree with `pal cli work` as it happens, not at the end: `open` a node for new work (`--root` when it is unrelated), update `stopped` when the state moves, `decide` when a choice is settled, `attach` what the work produced, `done` when it is finished. When the user switches topic mid-work, `park --stopped "…"` the current node before starting the new one. Once a scope has work, the `Work:` line in each prompt shows where you are.
59
60
 
60
61
  ---
61
62
 
@@ -27,6 +27,8 @@
27
27
  "skillMatching": true,
28
28
  "wallClock": true,
29
29
  "repoState": true,
30
+ "workTree": false,
31
+ "workTreeGate": false,
30
32
  "dueReminders": false,
31
33
  "interactionAwareness": true,
32
34
  "reactionSampling": true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portable-agent-layer",
3
- "version": "0.111.0",
3
+ "version": "0.113.0",
4
4
  "description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli/index.ts CHANGED
@@ -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: () => checkForUpdate(true),
572
+ check,
565
573
  reinstall: (env) => {
566
574
  clearUpdateCache();
567
575
  return reinstallInFreshProcess(process.argv[1], env);
package/src/cli/tree.ts CHANGED
@@ -30,6 +30,7 @@ import { serverCommand } from "./server";
30
30
  import { skillCommand } from "./skill";
31
31
  import { subagentCommand } from "./subagent";
32
32
  import { usageCommand } from "./usage";
33
+ import { workCommand } from "./work";
33
34
  import { wrappedCommand } from "./wrapped";
34
35
 
35
36
  type Handler = Leaf["run"];
@@ -108,7 +109,17 @@ function adminCommands(admin: AdminHandlers): Record<string, Leaf | Group> {
108
109
  options: agentOptions("Uninstall from"),
109
110
  run: admin.uninstall,
110
111
  }),
111
- update: leaf({ summary: "Update PAL (git pull or bun add -g)", run: admin.update }),
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
+ }),
112
123
  export: leaf({
113
124
  summary: "Export your state to a zip",
114
125
  args: "[path]",
@@ -171,6 +182,7 @@ export function cliTree(admin: AdminHandlers): Group {
171
182
  ledger: ledgerCommand,
172
183
  rule: ruleCommand,
173
184
  correction: correctionCommand,
185
+ work: workCommand,
174
186
  server: serverCommand,
175
187
  skill: skillCommand,
176
188
  subagent: subagentCommand,
@@ -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
@@ -15,16 +15,22 @@ export interface WhatsNew {
15
15
 
16
16
  const SHOWN = 3;
17
17
 
18
- function parts(version: string): number[] {
19
- return version.replace(/^v/, "").split(".").map(Number);
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
- const [x, y] = [parts(a), parts(b)];
24
- for (let i = 0; i < 3; i++) {
25
- if ((x[i] ?? 0) !== (y[i] ?? 0)) return (x[i] ?? 0) > (y[i] ?? 0);
26
- }
27
- return false;
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) => newer(r.tag_name, from) && !newer(r.tag_name, to))
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 {
@@ -0,0 +1,344 @@
1
+ /**
2
+ * pal cli work — the work tree: what is active, what is parked and where it
3
+ * stopped, the decisions and artifacts of each piece of work.
4
+ */
5
+
6
+ import {
7
+ ARTIFACT_KINDS,
8
+ type ArtifactKind,
9
+ appendEvent,
10
+ findNode,
11
+ newNodeId,
12
+ readTree,
13
+ type ScopeTree,
14
+ scopeOf,
15
+ type WorkNode,
16
+ } from "../hooks/lib/work-tree";
17
+ import {
18
+ renderNodeDetail,
19
+ renderSessionContext,
20
+ renderTreeList,
21
+ suffix,
22
+ } from "../hooks/lib/work-tree-render";
23
+ import { group, leaf, runCommand, UsageError } from "../tools/lib/command";
24
+
25
+ const SCOPE = {
26
+ type: "string",
27
+ description: "Project or folder the work belongs to (default: the current one)",
28
+ } as const;
29
+ const NODE = {
30
+ type: "string",
31
+ description: "Node id or unambiguous prefix (default: the active node)",
32
+ } as const;
33
+
34
+ class NodeError extends Error {}
35
+
36
+ function scopeFrom(values: { scope?: string }): string {
37
+ return values.scope ?? scopeOf(process.cwd());
38
+ }
39
+
40
+ function requireText(text: string | undefined, what: string): string {
41
+ if (!text?.trim()) throw new UsageError(`${what} is required`);
42
+ return text.trim();
43
+ }
44
+
45
+ function targetNode(tree: ScopeTree, idOrPrefix: string | undefined): WorkNode {
46
+ if (idOrPrefix) {
47
+ const node = findNode(tree, idOrPrefix);
48
+ if (!node)
49
+ throw new NodeError(`No single node matches "${idOrPrefix}" in ${tree.scope}`);
50
+ return node;
51
+ }
52
+ const active = tree.active ? tree.nodes.get(tree.active) : undefined;
53
+ if (!active)
54
+ throw new NodeError(
55
+ `No active node in ${tree.scope}; pass --node, or open or enter one first`
56
+ );
57
+ return active;
58
+ }
59
+
60
+ function guarded(run: () => void): number {
61
+ try {
62
+ run();
63
+ return 0;
64
+ } catch (err) {
65
+ if (!(err instanceof NodeError)) throw err;
66
+ console.error(err.message);
67
+ return 1;
68
+ }
69
+ }
70
+
71
+ function record<T extends Parameters<typeof appendEvent>[2]>(
72
+ values: { scope?: string; node?: string },
73
+ type: T,
74
+ payload: Parameters<typeof appendEvent<T>>[3],
75
+ done: (node: WorkNode) => string
76
+ ): number {
77
+ return guarded(() => {
78
+ const scope = scopeFrom(values);
79
+ const node = targetNode(readTree(scope), values.node);
80
+ appendEvent(scope, node.id, type, payload);
81
+ console.log(done(node));
82
+ });
83
+ }
84
+
85
+ function parentFor(tree: ScopeTree, under: string | undefined, root: boolean) {
86
+ if (root) return null;
87
+ if (under) return targetNode(tree, under).id;
88
+ return tree.active;
89
+ }
90
+
91
+ function optionalIsc(isc: string | undefined): number | undefined {
92
+ if (isc === undefined) return undefined;
93
+ const parsed = Number(isc);
94
+ if (!Number.isInteger(parsed) || parsed < 1)
95
+ throw new UsageError(`--isc must be an ISC number, got "${isc}"`);
96
+ return parsed;
97
+ }
98
+
99
+ function cmdOpen(
100
+ title: string | undefined,
101
+ values: {
102
+ scope?: string;
103
+ under?: string;
104
+ root?: boolean;
105
+ isc?: string;
106
+ commitment?: string;
107
+ }
108
+ ): number {
109
+ const text = requireText(title, "A title");
110
+ const isc = optionalIsc(values.isc);
111
+ return guarded(() => {
112
+ const scope = scopeFrom(values);
113
+ const tree = readTree(scope);
114
+ const parent = parentFor(tree, values.under, values.root === true);
115
+ const id = newNodeId();
116
+ const links = {
117
+ ...(isc !== undefined && { isc }),
118
+ ...(values.commitment && { commitment: values.commitment }),
119
+ };
120
+ appendEvent(scope, id, "open", { title: text, parent, links });
121
+ const under = parent ? ` under ${tree.nodes.get(parent)?.title ?? parent}` : "";
122
+ console.log(`Opened ${text} [${id}]${under}; it is now active.`);
123
+ });
124
+ }
125
+
126
+ function backTo(scope: string): string {
127
+ const tree = readTree(scope);
128
+ const active = tree.active ? tree.nodes.get(tree.active) : undefined;
129
+ if (!active) return "No node is active now.";
130
+ return `Back to ${active.title} [${active.id}]${suffix(", stopped at: ", active.stoppedAt)}.`;
131
+ }
132
+
133
+ function cmdDone(values: { scope?: string; node?: string; summary?: string }): number {
134
+ return guarded(() => {
135
+ const scope = scopeFrom(values);
136
+ const node = targetNode(readTree(scope), values.node);
137
+ appendEvent(
138
+ scope,
139
+ node.id,
140
+ "done",
141
+ values.summary ? { summary: values.summary } : {}
142
+ );
143
+ console.log(`Done: ${node.title} [${node.id}]. ${backTo(scope)}`);
144
+ });
145
+ }
146
+
147
+ function artifactKind(kind: string | undefined): ArtifactKind {
148
+ if (kind && (ARTIFACT_KINDS as readonly string[]).includes(kind))
149
+ return kind as ArtifactKind;
150
+ throw new UsageError(
151
+ `kind must be one of ${ARTIFACT_KINDS.join(", ")}, got "${kind ?? ""}"`
152
+ );
153
+ }
154
+
155
+ function cmdShow(values: { scope?: string; node?: string; json?: boolean }): number {
156
+ return guarded(() => {
157
+ const tree = readTree(scopeFrom(values));
158
+ if (!values.node && !tree.active) {
159
+ console.log(
160
+ renderSessionContext(tree) || `No work recorded for ${tree.scope} yet.`
161
+ );
162
+ return;
163
+ }
164
+ const node = targetNode(tree, values.node);
165
+ console.log(
166
+ values.json ? JSON.stringify(node, null, 2) : renderNodeDetail(tree, node)
167
+ );
168
+ });
169
+ }
170
+
171
+ function cmdList(values: { scope?: string; all?: boolean; json?: boolean }): number {
172
+ const tree = readTree(scopeFrom(values));
173
+ if (values.json) {
174
+ console.log(JSON.stringify({ ...tree, nodes: [...tree.nodes.values()] }, null, 2));
175
+ return 0;
176
+ }
177
+ console.log(renderTreeList(tree, values.all === true));
178
+ return 0;
179
+ }
180
+
181
+ export const workCommand = group({
182
+ summary: "The work tree: what is active, parked, waiting, and where each piece stopped",
183
+ fallback: "show",
184
+ commands: {
185
+ show: leaf({
186
+ summary: "The active node in full, or the whole picture when none is active",
187
+ options: {
188
+ scope: SCOPE,
189
+ node: NODE,
190
+ json: { type: "boolean", description: "As JSON" },
191
+ },
192
+ run: ({ values }) => cmdShow(values),
193
+ }),
194
+ list: leaf({
195
+ summary: "Every open node as a tree",
196
+ options: {
197
+ scope: SCOPE,
198
+ all: { type: "boolean", description: "Include finished nodes" },
199
+ json: { type: "boolean", description: "As JSON" },
200
+ },
201
+ run: ({ values }) => cmdList(values),
202
+ }),
203
+ open: leaf({
204
+ summary: "Open a node and make it active (a child of the active node by default)",
205
+ args: "<title...>",
206
+ options: {
207
+ scope: SCOPE,
208
+ under: { type: "string", description: "Parent node id or prefix" },
209
+ root: {
210
+ type: "boolean",
211
+ description: "Open at the top level, not under the active node",
212
+ },
213
+ isc: { type: "string", description: "The project ISC this node works on" },
214
+ commitment: {
215
+ type: "string",
216
+ description: "The commitment id this node fulfils",
217
+ },
218
+ },
219
+ run: ({ positionals, values }) => cmdOpen(positionals.join(" "), values),
220
+ }),
221
+ enter: leaf({
222
+ summary: "Make a node active, parking the one that was",
223
+ args: "<node>",
224
+ options: { scope: SCOPE },
225
+ run: ({ positionals, values }) =>
226
+ record(
227
+ { ...values, node: requireText(positionals[0], "A node") },
228
+ "enter",
229
+ {},
230
+ (n) => `Entered ${n.title} [${n.id}]${suffix(", stopped at: ", n.stoppedAt)}.`
231
+ ),
232
+ }),
233
+ park: leaf({
234
+ summary: "Park a node, saying where it stopped",
235
+ options: {
236
+ scope: SCOPE,
237
+ node: NODE,
238
+ stopped: { type: "string", description: "Where it stopped, to resume from" },
239
+ },
240
+ run: ({ values }) =>
241
+ record(
242
+ values,
243
+ "park",
244
+ values.stopped ? { stoppedAt: values.stopped } : {},
245
+ (n) => `Parked ${n.title} [${n.id}].`
246
+ ),
247
+ }),
248
+ wait: leaf({
249
+ summary: "Mark a node as waiting on the user",
250
+ args: "<waits-on...>",
251
+ options: { scope: SCOPE, node: NODE },
252
+ run: ({ positionals, values }) =>
253
+ record(
254
+ values,
255
+ "wait",
256
+ { on: requireText(positionals.join(" "), "What it waits on") },
257
+ (n) => `${n.title} [${n.id}] is waiting on the user.`
258
+ ),
259
+ }),
260
+ done: leaf({
261
+ summary: "Finish a node; a finished detour hands the work back to its parent",
262
+ options: {
263
+ scope: SCOPE,
264
+ node: NODE,
265
+ summary: { type: "string", description: "One line on what it ended with" },
266
+ },
267
+ run: ({ values }) => cmdDone(values),
268
+ }),
269
+ stopped: leaf({
270
+ summary: "Record where the work stands now",
271
+ args: "<text...>",
272
+ options: { scope: SCOPE, node: NODE },
273
+ run: ({ positionals, values }) =>
274
+ record(
275
+ values,
276
+ "stopped",
277
+ { text: requireText(positionals.join(" "), "Where it stopped") },
278
+ (n) => `Stopped-at recorded on ${n.title} [${n.id}].`
279
+ ),
280
+ }),
281
+ decide: leaf({
282
+ summary: "Record a decision on a node",
283
+ args: "<decision...>",
284
+ options: {
285
+ scope: SCOPE,
286
+ node: NODE,
287
+ why: { type: "string", description: "The reason" },
288
+ },
289
+ run: ({ positionals, values }) =>
290
+ record(
291
+ values,
292
+ "decide",
293
+ {
294
+ text: requireText(positionals.join(" "), "A decision"),
295
+ ...(values.why && { why: values.why }),
296
+ },
297
+ (n) => `Decision recorded on ${n.title} [${n.id}].`
298
+ ),
299
+ }),
300
+ attach: leaf({
301
+ summary: `Attach an artifact to a node (${ARTIFACT_KINDS.join(", ")})`,
302
+ args: "<kind> <ref...>",
303
+ options: {
304
+ scope: SCOPE,
305
+ node: NODE,
306
+ label: { type: "string", description: "What it is" },
307
+ },
308
+ run: ({ positionals, values }) =>
309
+ record(
310
+ values,
311
+ "attach",
312
+ {
313
+ kind: artifactKind(positionals[0]),
314
+ ref: requireText(positionals.slice(1).join(" "), "A ref"),
315
+ ...(values.label && { label: values.label }),
316
+ },
317
+ (n) => `Attached to ${n.title} [${n.id}].`
318
+ ),
319
+ }),
320
+ "follow-up": leaf({
321
+ summary: "Record a follow-up on a node",
322
+ args: "<text...>",
323
+ options: {
324
+ scope: SCOPE,
325
+ node: NODE,
326
+ due: { type: "string", description: "When it is due, such as 2026-10-25" },
327
+ },
328
+ run: ({ positionals, values }) =>
329
+ record(
330
+ values,
331
+ "follow-up",
332
+ {
333
+ text: requireText(positionals.join(" "), "A follow-up"),
334
+ ...(values.due && { due: values.due }),
335
+ },
336
+ (n) => `Follow-up recorded on ${n.title} [${n.id}].`
337
+ ),
338
+ }),
339
+ },
340
+ });
341
+
342
+ export function runWork(args: string[]): Promise<number> {
343
+ return runCommand(workCommand, args, ["pal", "cli", "work"]);
344
+ }
@@ -20,6 +20,7 @@ import { logError } from "./lib/log";
20
20
  import { isPalSpawnedInference } from "./lib/spawn-guard";
21
21
  import { readStdinJSON } from "./lib/stdin";
22
22
  import { type StopTurnPayload, stopTurn } from "./lib/stop";
23
+ import { stopWorkTree } from "./lib/work-tree-gate";
23
24
 
24
25
  // Recursion guard — spawned inference subprocesses must not record session
25
26
  // learning, ratings, or handoffs from their throwaway transcript.
@@ -62,4 +63,13 @@ try {
62
63
  } catch (err) {
63
64
  logError("StopOrchestrator:adaptation-rules", err);
64
65
  }
66
+ try {
67
+ const sendBack = stopWorkTree(payload);
68
+ if (sendBack) {
69
+ process.stdout.write(stopBlockResponse(sendBack));
70
+ process.exit(0);
71
+ }
72
+ } catch (err) {
73
+ logError("StopOrchestrator:work-tree", err);
74
+ }
65
75
  await stopTurn(payload);
@@ -22,6 +22,7 @@ import { isEnabled } from "../lib/settings";
22
22
  import { getSkillReminder } from "../lib/skill-match";
23
23
  import { getSteeringReminder } from "../lib/steering";
24
24
  import { getWallClockReminder } from "../lib/wall-clock";
25
+ import { workTreePromptLine } from "../lib/work-tree-context";
25
26
 
26
27
  const BUDGET_MS = 250;
27
28
 
@@ -88,6 +89,7 @@ export async function getPromptContext(
88
89
  getWallClockReminder(),
89
90
  userText && withinBudget(() => promptRulesReminder(userText, sessionId), BUDGET_MS),
90
91
  withinBudget(() => getRepoStateReminder(), BUDGET_MS),
92
+ withinBudget(() => workTreePromptLine(userText), BUDGET_MS),
91
93
  userText && withinBudget(() => observeTurn(userText, sessionId), BUDGET_MS),
92
94
  withinBudget(() => dueNudgeReminder(), BUDGET_MS),
93
95
  userText && getSteeringReminder(userText),
@@ -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
- async function checkNpm(): Promise<UpdateCache> {
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 fetch(
147
- "https://registry.npmjs.org/portable-agent-layer/latest",
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) {
@@ -139,21 +139,22 @@ function isUserPrompt(entry: TranscriptLine): boolean {
139
139
 
140
140
  const ran = (input: unknown) => JSON.stringify(input ?? {});
141
141
 
142
- function commandsIn(entry: TranscriptLine): string[] {
142
+ function callsIn(entry: TranscriptLine, tools: ReadonlySet<string>): string[] {
143
143
  if (entry.type === "PLANNER_RESPONSE")
144
144
  return toolCallsOf(entry)
145
- .filter((call) => COMMAND_TOOLS.has(call.name ?? ""))
145
+ .filter((call) => tools.has(call.name ?? ""))
146
146
  .map((call) => ran(call.args));
147
147
  if (entry.type === "assistant")
148
148
  return blocks(entry.message?.content)
149
- .filter((b) => b.type === "tool_use" && COMMAND_TOOLS.has(b.name ?? ""))
149
+ .filter((b) => b.type === "tool_use" && tools.has(b.name ?? ""))
150
150
  .map((b) => ran(b.input));
151
151
  const call = entry.payload;
152
152
  if (entry.type !== "response_item" || !call) return [];
153
153
  const input = call.input ?? call.arguments ?? call.action;
154
- if (call.type === "local_shell_call") return [ran(input)];
154
+ if (call.type === "local_shell_call")
155
+ return tools === COMMAND_TOOLS ? [ran(input)] : [];
155
156
  const isCall = call.type === "function_call" || call.type === "custom_tool_call";
156
- return isCall && COMMAND_TOOLS.has(call.name ?? "") ? [ran(input)] : [];
157
+ return isCall && tools.has(call.name ?? "") ? [ran(input)] : [];
157
158
  }
158
159
 
159
160
  function isTurnEntry(entry: TranscriptLine): boolean {
@@ -162,17 +163,24 @@ function isTurnEntry(entry: TranscriptLine): boolean {
162
163
  );
163
164
  }
164
165
 
165
- /** What each command this turn ran. Null when no line reads as a turn, so an
166
- * unknown transcript format never counts as none. */
167
- export function commandsThisTurn(lines: string[]): string[] | null {
166
+ /** The input of each call this turn made to one of the tools. Null when no line reads
167
+ * as a turn, so an unknown transcript format never counts as none. */
168
+ export function callsThisTurn(
169
+ lines: string[],
170
+ tools: ReadonlySet<string>
171
+ ): string[] | null {
168
172
  const entries = lines.map(parsed).filter((e): e is TranscriptLine => e !== null);
169
173
  if (!entries.some(isTurnEntry)) return null;
170
- let commands: string[] = [];
174
+ let calls: string[] = [];
171
175
  for (const entry of entries) {
172
- if (isUserPrompt(entry)) commands = [];
173
- commands.push(...commandsIn(entry));
176
+ if (isUserPrompt(entry)) calls = [];
177
+ calls.push(...callsIn(entry, tools));
174
178
  }
175
- return commands;
179
+ return calls;
180
+ }
181
+
182
+ export function commandsThisTurn(lines: string[]): string[] | null {
183
+ return callsThisTurn(lines, COMMAND_TOOLS);
176
184
  }
177
185
 
178
186
  function isBacked(claim: string, commands: string[]): boolean {