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 +2 -1
- package/assets/schema/pal-settings.schema.json +8 -0
- package/assets/templates/AGENTS.md.template +1 -0
- package/assets/templates/pal-settings.json +2 -0
- package/package.json +1 -1
- package/src/cli/index.ts +13 -5
- package/src/cli/tree.ts +13 -1
- package/src/cli/update-flow.ts +15 -0
- package/src/cli/whats-new.ts +14 -8
- package/src/cli/work.ts +344 -0
- package/src/hooks/StopOrchestrator.ts +10 -0
- package/src/hooks/handlers/inject-retrieval.ts +2 -0
- package/src/hooks/handlers/update-check.ts +14 -3
- package/src/hooks/lib/claim-check.ts +20 -12
- package/src/hooks/lib/claim-log.ts +1 -1
- package/src/hooks/lib/claude-md.ts +11 -2
- package/src/hooks/lib/context.ts +3 -0
- package/src/hooks/lib/work-tree-context.ts +25 -0
- package/src/hooks/lib/work-tree-gate.ts +67 -0
- package/src/hooks/lib/work-tree-git.ts +98 -0
- package/src/hooks/lib/work-tree-render.ts +157 -0
- package/src/hooks/lib/work-tree.ts +307 -0
- package/src/tools/lib/command.ts +8 -0
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
|
|
package/package.json
CHANGED
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
|
@@ -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({
|
|
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,
|
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 {
|
package/src/cli/work.ts
ADDED
|
@@ -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
|
-
|
|
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) {
|
|
@@ -139,21 +139,22 @@ function isUserPrompt(entry: TranscriptLine): boolean {
|
|
|
139
139
|
|
|
140
140
|
const ran = (input: unknown) => JSON.stringify(input ?? {});
|
|
141
141
|
|
|
142
|
-
function
|
|
142
|
+
function callsIn(entry: TranscriptLine, tools: ReadonlySet<string>): string[] {
|
|
143
143
|
if (entry.type === "PLANNER_RESPONSE")
|
|
144
144
|
return toolCallsOf(entry)
|
|
145
|
-
.filter((call) =>
|
|
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" &&
|
|
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")
|
|
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 &&
|
|
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
|
-
/**
|
|
166
|
-
* unknown transcript format never counts as none. */
|
|
167
|
-
export function
|
|
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
|
|
174
|
+
let calls: string[] = [];
|
|
171
175
|
for (const entry of entries) {
|
|
172
|
-
if (isUserPrompt(entry))
|
|
173
|
-
|
|
176
|
+
if (isUserPrompt(entry)) calls = [];
|
|
177
|
+
calls.push(...callsIn(entry, tools));
|
|
174
178
|
}
|
|
175
|
-
return
|
|
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 {
|