portable-agent-layer 0.111.0 → 0.112.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 +1 -0
- 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/tree.ts +2 -0
- 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/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
|
@@ -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/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"];
|
|
@@ -171,6 +172,7 @@ export function cliTree(admin: AdminHandlers): Group {
|
|
|
171
172
|
ledger: ledgerCommand,
|
|
172
173
|
rule: ruleCommand,
|
|
173
174
|
correction: correctionCommand,
|
|
175
|
+
work: workCommand,
|
|
174
176
|
server: serverCommand,
|
|
175
177
|
skill: skillCommand,
|
|
176
178
|
subagent: subagentCommand,
|
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,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 {
|
|
@@ -40,7 +40,7 @@ function monthFile(date: Date): string {
|
|
|
40
40
|
return resolve(claimsDir(), `${date.toISOString().slice(0, 7)}.jsonl`);
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
function transcriptLines(payload: StopTurnPayload): string[] {
|
|
43
|
+
export function transcriptLines(payload: StopTurnPayload): string[] {
|
|
44
44
|
const path = payload.transcript_path ?? payload.transcriptPath;
|
|
45
45
|
if (!path || !existsSync(path)) return [];
|
|
46
46
|
return readFileSync(path, "utf-8").split("\n").filter(Boolean);
|
|
@@ -110,7 +110,16 @@ export function needsRebuild(): boolean {
|
|
|
110
110
|
return latestMtime(...sources) > outputMtime;
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
-
import { identity } from "./settings";
|
|
113
|
+
import { identity, isOptedIn } from "./settings";
|
|
114
|
+
|
|
115
|
+
const OPT_IN_LINE_RE = /^\{\{IF (\w+)\}\}(.*\n?)/gm;
|
|
116
|
+
|
|
117
|
+
/** A `{{IF key}}` line stays, without its prefix, only when that setting is opted into. */
|
|
118
|
+
function withOptedInLines(template: string): string {
|
|
119
|
+
return template.replace(OPT_IN_LINE_RE, (_, key: string, line: string) =>
|
|
120
|
+
isOptedIn(key) ? line : ""
|
|
121
|
+
);
|
|
122
|
+
}
|
|
114
123
|
|
|
115
124
|
/** Render AGENTS.md from the template using current state */
|
|
116
125
|
export function buildClaudeMd(): string {
|
|
@@ -120,7 +129,7 @@ export function buildClaudeMd(): string {
|
|
|
120
129
|
|
|
121
130
|
const id = identity();
|
|
122
131
|
|
|
123
|
-
return template
|
|
132
|
+
return withOptedInLines(template)
|
|
124
133
|
.replaceAll("{{IDENTITY_NAME}}", id.ai.name)
|
|
125
134
|
.replaceAll("{{IDENTITY_DISPLAY}}", id.ai.displayName)
|
|
126
135
|
.replaceAll("{{IDENTITY_CATCHPHRASE}}", id.ai.catchphrase)
|
package/src/hooks/lib/context.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { sessionDir } from "./session-dir";
|
|
|
18
18
|
import * as settings from "./settings";
|
|
19
19
|
import { readFramePrinciples } from "./wisdom";
|
|
20
20
|
import { lastSessionTitle } from "./work-tracking";
|
|
21
|
+
import { loadWorkTreeContext } from "./work-tree-context";
|
|
21
22
|
|
|
22
23
|
/** Load and concatenate loadAtStartup files */
|
|
23
24
|
function loadStartupFiles(): string {
|
|
@@ -223,10 +224,12 @@ export function buildSystemReminder(
|
|
|
223
224
|
!opts.withoutHandoff && settings.isEnabled("handoff")
|
|
224
225
|
? loadHandoffContext(sessionDir())
|
|
225
226
|
: "";
|
|
227
|
+
const workTree = loadWorkTreeContext(sessionDir());
|
|
226
228
|
const dueNudges = opts.agent === "cursor" ? dueNudgeSections() : [];
|
|
227
229
|
const parts: string[] = [];
|
|
228
230
|
if (startup) parts.push(startup);
|
|
229
231
|
if (handoff) parts.push(handoff);
|
|
232
|
+
if (workTree) parts.push(workTree);
|
|
230
233
|
parts.push(...dueNudges);
|
|
231
234
|
if (selfModel) parts.push(selfModel);
|
|
232
235
|
if (wisdom) parts.push(wisdom);
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the work tree enters an agent's context: the block every new or
|
|
3
|
+
* compacted session starts with, and the line every prompt carries.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { reactionTo } from "./interaction-reaction";
|
|
7
|
+
import { sessionDir } from "./session-dir";
|
|
8
|
+
import { isOptedIn } from "./settings";
|
|
9
|
+
import { readTree, scopeOf } from "./work-tree";
|
|
10
|
+
import { renderPromptLine, renderSessionContext } from "./work-tree-render";
|
|
11
|
+
|
|
12
|
+
export function loadWorkTreeContext(cwd: string = sessionDir()): string {
|
|
13
|
+
if (!isOptedIn("workTree")) return "";
|
|
14
|
+
return renderSessionContext(readTree(scopeOf(cwd)));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function workTreePromptLine(
|
|
18
|
+
userText: string,
|
|
19
|
+
cwd: string = sessionDir()
|
|
20
|
+
): string | null {
|
|
21
|
+
if (!isOptedIn("workTree")) return null;
|
|
22
|
+
const newTopic = userText !== "" && reactionTo(userText, undefined) === "new-topic";
|
|
23
|
+
const line = renderPromptLine(readTree(scopeOf(cwd)), newTopic);
|
|
24
|
+
return line ? `<system-reminder>${line}</system-reminder>` : null;
|
|
25
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The end-of-turn check that keeps the work tree true: a turn that produced
|
|
3
|
+
* something (a commit, a branch, a PR, a published artifact) and recorded
|
|
4
|
+
* nothing in the tree is sent back once to record it.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { callsThisTurn, commandsThisTurn } from "./claim-check";
|
|
8
|
+
import { alreadySentBack, transcriptLines } from "./claim-log";
|
|
9
|
+
import { sessionDir } from "./session-dir";
|
|
10
|
+
import { isOptedIn } from "./settings";
|
|
11
|
+
import type { StopTurnPayload } from "./stop";
|
|
12
|
+
import { readTree, scopeOf } from "./work-tree";
|
|
13
|
+
import { attachGitProgress } from "./work-tree-git";
|
|
14
|
+
import { renderPromptLine } from "./work-tree-render";
|
|
15
|
+
|
|
16
|
+
const RECORDS_WORK_RE = /\bpal\s+cli\s+work\b/;
|
|
17
|
+
|
|
18
|
+
const PRODUCERS: { what: string; re: RegExp }[] = [
|
|
19
|
+
{ what: "committed", re: /\bgit\s+commit\b/ },
|
|
20
|
+
{ what: "made a branch", re: /\bgit\s+(?:checkout\s+-b|switch\s+-c|branch\s+(?!-)\S)/ },
|
|
21
|
+
{ what: "opened a PR", re: /\bgh\s+pr\s+create\b/ },
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const ARTIFACT_TOOLS: ReadonlySet<string> = new Set(["Artifact"]);
|
|
25
|
+
const NON_PUBLISH_ACTION_RE = /"action":"(?!publish")/;
|
|
26
|
+
|
|
27
|
+
function producedThisTurn(commands: string[], lines: string[]): string[] {
|
|
28
|
+
const produced = PRODUCERS.filter((p) => commands.some((c) => p.re.test(c))).map(
|
|
29
|
+
(p) => p.what
|
|
30
|
+
);
|
|
31
|
+
const artifacts = callsThisTurn(lines, ARTIFACT_TOOLS) ?? [];
|
|
32
|
+
if (artifacts.some((call) => !NON_PUBLISH_ACTION_RE.test(call)))
|
|
33
|
+
produced.push("published an artifact");
|
|
34
|
+
return produced;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function sendBackReason(produced: string[], scope: string): string {
|
|
38
|
+
const where = renderPromptLine(readTree(scope), false) ?? "Work: nothing recorded yet";
|
|
39
|
+
return [
|
|
40
|
+
`This turn ${produced.join(", ")} but recorded nothing in the work tree (${where}).`,
|
|
41
|
+
'Record where the work stands with `pal cli work stopped "…"`, plus `decide` or `attach` where they fit;',
|
|
42
|
+
'if no node fits, open one first with `pal cli work open "…"` (`--root` for unrelated work).',
|
|
43
|
+
].join(" ");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function workTreeSendBack(
|
|
47
|
+
payload: StopTurnPayload,
|
|
48
|
+
lines: string[],
|
|
49
|
+
cwd: string
|
|
50
|
+
): string | null {
|
|
51
|
+
if (!isOptedIn("workTree") || !isOptedIn("workTreeGate")) return null;
|
|
52
|
+
if (alreadySentBack(payload)) return null;
|
|
53
|
+
const commands = commandsThisTurn(lines);
|
|
54
|
+
if (commands === null || commands.some((c) => RECORDS_WORK_RE.test(c))) return null;
|
|
55
|
+
const produced = producedThisTurn(commands, lines);
|
|
56
|
+
return produced.length > 0 ? sendBackReason(produced, scopeOf(cwd)) : null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The reason to send the turn back with, or null after attaching what git moved. */
|
|
60
|
+
export function stopWorkTree(payload: StopTurnPayload | null): string | null {
|
|
61
|
+
if (!payload || !isOptedIn("workTree")) return null;
|
|
62
|
+
const cwd = sessionDir();
|
|
63
|
+
const sendBack = workTreeSendBack(payload, transcriptLines(payload), cwd);
|
|
64
|
+
if (sendBack) return sendBack;
|
|
65
|
+
attachGitProgress(cwd);
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* New commits and branch switches land on the active work-tree node at the end
|
|
3
|
+
* of a turn, so the tree knows what the work produced without being told.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
import { appendEvent, readTree, scopeOf } from "./work-tree";
|
|
8
|
+
|
|
9
|
+
const GIT_TIMEOUT_MS = 1000;
|
|
10
|
+
const MAX_COMMITS = 20;
|
|
11
|
+
|
|
12
|
+
interface GitSnapshot {
|
|
13
|
+
branch: string;
|
|
14
|
+
head: string;
|
|
15
|
+
email: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface Commit {
|
|
19
|
+
sha: string;
|
|
20
|
+
subject: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function git(cwd: string, ...args: string[]): string | null {
|
|
24
|
+
const result = spawnSync("git", ["-C", cwd, ...args], {
|
|
25
|
+
encoding: "utf-8",
|
|
26
|
+
timeout: GIT_TIMEOUT_MS,
|
|
27
|
+
});
|
|
28
|
+
return result.status === 0 ? result.stdout.trim() : null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function snapshot(cwd: string): GitSnapshot | null {
|
|
32
|
+
const head = git(cwd, "rev-parse", "HEAD");
|
|
33
|
+
const branch = git(cwd, "rev-parse", "--abbrev-ref", "HEAD");
|
|
34
|
+
if (!head || !branch) return null;
|
|
35
|
+
return { head, branch, email: git(cwd, "config", "user.email") ?? "" };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Only the user's own commits: a pull brings other people's work, which this turn did not make. */
|
|
39
|
+
function ownCommitsSince(cwd: string, from: string, to: string, email: string): Commit[] {
|
|
40
|
+
const log = git(
|
|
41
|
+
cwd,
|
|
42
|
+
"log",
|
|
43
|
+
`-${MAX_COMMITS}`,
|
|
44
|
+
"--format=%h%x09%ae%x09%s",
|
|
45
|
+
`${from}..${to}`
|
|
46
|
+
);
|
|
47
|
+
if (!log) return [];
|
|
48
|
+
return log
|
|
49
|
+
.split("\n")
|
|
50
|
+
.map((line) => line.split("\t"))
|
|
51
|
+
.filter(([, author]) => author === email)
|
|
52
|
+
.map(([sha, , subject]) => ({ sha, subject }))
|
|
53
|
+
.reverse();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A scope with no work yet is left alone, so visiting a repo writes nothing. */
|
|
57
|
+
export function attachGitProgress(
|
|
58
|
+
cwd: string,
|
|
59
|
+
file?: string,
|
|
60
|
+
now: Date = new Date()
|
|
61
|
+
): void {
|
|
62
|
+
const scope = scopeOf(cwd);
|
|
63
|
+
const tree = readTree(scope, file);
|
|
64
|
+
if (tree.nodes.size === 0) return;
|
|
65
|
+
const current = snapshot(cwd);
|
|
66
|
+
if (!current) return;
|
|
67
|
+
const cursor = tree.gitCursor;
|
|
68
|
+
if (cursor?.head === current.head && cursor.branch === current.branch) return;
|
|
69
|
+
|
|
70
|
+
if (cursor && tree.active) {
|
|
71
|
+
if (cursor.branch !== current.branch)
|
|
72
|
+
appendEvent(
|
|
73
|
+
scope,
|
|
74
|
+
tree.active,
|
|
75
|
+
"attach",
|
|
76
|
+
{ kind: "branch", ref: current.branch },
|
|
77
|
+
file,
|
|
78
|
+
now
|
|
79
|
+
);
|
|
80
|
+
for (const commit of ownCommitsSince(cwd, cursor.head, current.head, current.email))
|
|
81
|
+
appendEvent(
|
|
82
|
+
scope,
|
|
83
|
+
tree.active,
|
|
84
|
+
"attach",
|
|
85
|
+
{ kind: "commit", ref: commit.sha, label: commit.subject },
|
|
86
|
+
file,
|
|
87
|
+
now
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
appendEvent(
|
|
91
|
+
scope,
|
|
92
|
+
"",
|
|
93
|
+
"git-cursor",
|
|
94
|
+
{ branch: current.branch, head: current.head },
|
|
95
|
+
file,
|
|
96
|
+
now
|
|
97
|
+
);
|
|
98
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How the work tree reads as text: the CLI's views, the block a new or
|
|
3
|
+
* compacted session starts with, and the one line every prompt carries.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { activePath, nodesWithStatus, type ScopeTree, type WorkNode } from "./work-tree";
|
|
7
|
+
|
|
8
|
+
const SESSION_BUDGET = 2_000;
|
|
9
|
+
const RECENT_DECISIONS = 3;
|
|
10
|
+
const LISTED_PARKED = 5;
|
|
11
|
+
const PROMPT_LINE_MAX = 300;
|
|
12
|
+
|
|
13
|
+
const RECORD_HINT =
|
|
14
|
+
"Record as you go: pal cli work stopped|decide|attach|follow-up|open|done (pal cli work --help)";
|
|
15
|
+
|
|
16
|
+
export function suffix(
|
|
17
|
+
lead: string,
|
|
18
|
+
value: string | null | undefined,
|
|
19
|
+
tail = ""
|
|
20
|
+
): string {
|
|
21
|
+
return value ? `${lead}${value}${tail}` : "";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function clip(text: string, max: number): string {
|
|
25
|
+
return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function label(node: WorkNode): string {
|
|
29
|
+
return `${node.title} [${node.id}]`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function stoppedClause(node: WorkNode): string {
|
|
33
|
+
return node.stoppedAt
|
|
34
|
+
? ` — stopped at: ${node.stoppedAt}`
|
|
35
|
+
: " — no stopped-at recorded";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function due(followUp: { due?: string }): string {
|
|
39
|
+
return followUp.due ? ` (due ${followUp.due.slice(0, 10)})` : "";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function renderNodeDetail(tree: ScopeTree, node: WorkNode): string {
|
|
43
|
+
const path = activePath({ ...tree, active: node.id })
|
|
44
|
+
.map((n) => n.title)
|
|
45
|
+
.join(" › ");
|
|
46
|
+
const lines = [
|
|
47
|
+
`${label(node)} — ${node.status}`,
|
|
48
|
+
`Path: ${path}`,
|
|
49
|
+
`Stopped at: ${node.stoppedAt ?? "(not recorded)"}`,
|
|
50
|
+
];
|
|
51
|
+
if (node.waitingOn) lines.push(`Waiting on: ${node.waitingOn}`);
|
|
52
|
+
if (node.summary) lines.push(`Summary: ${node.summary}`);
|
|
53
|
+
if (node.links.isc !== undefined) lines.push(`ISC: ${node.links.isc}`);
|
|
54
|
+
if (node.links.commitment) lines.push(`Commitment: ${node.links.commitment}`);
|
|
55
|
+
const section = (title: string, rows: string[]) => {
|
|
56
|
+
if (rows.length) lines.push(`${title}:`, ...rows.map((r) => ` - ${r}`));
|
|
57
|
+
};
|
|
58
|
+
section(
|
|
59
|
+
"Decisions",
|
|
60
|
+
node.decisions.map(
|
|
61
|
+
(d) => `${d.ts.slice(0, 10)} ${d.text}${suffix(" (why: ", d.why, ")")}`
|
|
62
|
+
)
|
|
63
|
+
);
|
|
64
|
+
section(
|
|
65
|
+
"Artifacts",
|
|
66
|
+
node.artifacts.map((a) => `${a.kind} ${a.ref}${suffix(" — ", a.label)}`)
|
|
67
|
+
);
|
|
68
|
+
section(
|
|
69
|
+
"Follow-ups",
|
|
70
|
+
node.followUps.map((f) => `${f.text}${due(f)}`)
|
|
71
|
+
);
|
|
72
|
+
return lines.join("\n");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function childrenOf(tree: ScopeTree, parent: string | null): WorkNode[] {
|
|
76
|
+
return [...tree.nodes.values()]
|
|
77
|
+
.filter(
|
|
78
|
+
(n) => n.parent === parent || (parent === null && !tree.nodes.has(n.parent ?? ""))
|
|
79
|
+
)
|
|
80
|
+
.sort((a, b) => a.opened.localeCompare(b.opened));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const MARK: Record<WorkNode["status"], string> = {
|
|
84
|
+
active: "▶",
|
|
85
|
+
parked: "‖",
|
|
86
|
+
waiting: "?",
|
|
87
|
+
done: "✓",
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export function renderTreeList(tree: ScopeTree, includeDone: boolean): string {
|
|
91
|
+
const lines: string[] = [];
|
|
92
|
+
const walk = (node: WorkNode, depth: number) => {
|
|
93
|
+
if (node.status === "done" && !includeDone) return;
|
|
94
|
+
const stop = node.status === "done" ? "" : stoppedClause(node);
|
|
95
|
+
lines.push(`${" ".repeat(depth)}${MARK[node.status]} ${label(node)}${stop}`);
|
|
96
|
+
for (const child of childrenOf(tree, node.id)) walk(child, depth + 1);
|
|
97
|
+
};
|
|
98
|
+
for (const root of childrenOf(tree, null)) walk(root, 0);
|
|
99
|
+
return lines.length ? lines.join("\n") : `No work recorded for ${tree.scope} yet.`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function activeSection(tree: ScopeTree): string[] {
|
|
103
|
+
const path = activePath(tree);
|
|
104
|
+
const active = path.at(-1);
|
|
105
|
+
if (!active) return ["Active: none — enter a parked node or open one."];
|
|
106
|
+
const lines = [`Active: ${label(active)}${stoppedClause(active)}`];
|
|
107
|
+
if (path.length > 1) lines.push(` Inside: ${path.map((n) => n.title).join(" › ")}`);
|
|
108
|
+
for (const d of active.decisions.slice(-RECENT_DECISIONS))
|
|
109
|
+
lines.push(` Decided ${d.ts.slice(0, 10)}: ${d.text}`);
|
|
110
|
+
return lines;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function listSection(title: string, rows: string[]): string[] {
|
|
114
|
+
return rows.length ? [title, ...rows.map((r) => `- ${r}`)] : [];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** What a new or compacted session needs to pick the work back up without digging. */
|
|
118
|
+
export function renderSessionContext(tree: ScopeTree): string {
|
|
119
|
+
if (tree.nodes.size === 0) return "";
|
|
120
|
+
const nodes = [...tree.nodes.values()];
|
|
121
|
+
const open = nodes.filter((n) => n.status !== "done");
|
|
122
|
+
const lines = [
|
|
123
|
+
`## Work tree — ${tree.scope}`,
|
|
124
|
+
...activeSection(tree),
|
|
125
|
+
...listSection(
|
|
126
|
+
"Waiting on the user:",
|
|
127
|
+
nodesWithStatus(tree, "waiting").map((n) => `${label(n)}: ${n.waitingOn}`)
|
|
128
|
+
),
|
|
129
|
+
...listSection(
|
|
130
|
+
"Parked:",
|
|
131
|
+
nodesWithStatus(tree, "parked")
|
|
132
|
+
.slice(0, LISTED_PARKED)
|
|
133
|
+
.map((n) => `${label(n)}${stoppedClause(n)}`)
|
|
134
|
+
),
|
|
135
|
+
...listSection(
|
|
136
|
+
"Follow-ups:",
|
|
137
|
+
open.flatMap((n) => n.followUps.map((f) => `${f.text}${due(f)} — on ${n.title}`))
|
|
138
|
+
),
|
|
139
|
+
RECORD_HINT,
|
|
140
|
+
];
|
|
141
|
+
return clip(lines.join("\n"), SESSION_BUDGET);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const NEW_TOPIC_HINT =
|
|
145
|
+
'New topic: if this is separate work, park the active node with its stopped-at (pal cli work park --stopped "…") and open one for this (pal cli work open "…").';
|
|
146
|
+
|
|
147
|
+
/** The line every prompt carries, so the active node survives anything that drops context. */
|
|
148
|
+
export function renderPromptLine(tree: ScopeTree, newTopic: boolean): string | null {
|
|
149
|
+
if (tree.nodes.size === 0) return null;
|
|
150
|
+
const active = tree.active ? tree.nodes.get(tree.active) : undefined;
|
|
151
|
+
const parked = nodesWithStatus(tree, "parked").length;
|
|
152
|
+
const head = active
|
|
153
|
+
? `Work: ${label(active)}${stoppedClause(active)}`
|
|
154
|
+
: "Work: no active node";
|
|
155
|
+
const line = clip(`${head} · ${parked} parked`, PROMPT_LINE_MAX);
|
|
156
|
+
return newTopic ? `${line}\n${NEW_TOPIC_HINT}` : line;
|
|
157
|
+
}
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The work tree: what the user is working on, what is parked and where each
|
|
3
|
+
* piece of work stopped, kept as an append-only event log so a compaction, a
|
|
4
|
+
* new session or a detour never loses it. The tree is derived on every read;
|
|
5
|
+
* nothing but the events is stored.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { appendFileSync, existsSync, readFileSync } from "node:fs";
|
|
9
|
+
import { resolve } from "node:path";
|
|
10
|
+
import { ensureDir, paths } from "./paths";
|
|
11
|
+
import { readAllProjects, resolveProjectFromCwd } from "./projects";
|
|
12
|
+
|
|
13
|
+
export type NodeStatus = "active" | "parked" | "waiting" | "done";
|
|
14
|
+
|
|
15
|
+
export type ArtifactKind =
|
|
16
|
+
| "commit"
|
|
17
|
+
| "branch"
|
|
18
|
+
| "pr"
|
|
19
|
+
| "artifact"
|
|
20
|
+
| "file"
|
|
21
|
+
| "run"
|
|
22
|
+
| "link";
|
|
23
|
+
|
|
24
|
+
export const ARTIFACT_KINDS: readonly ArtifactKind[] = [
|
|
25
|
+
"commit",
|
|
26
|
+
"branch",
|
|
27
|
+
"pr",
|
|
28
|
+
"artifact",
|
|
29
|
+
"file",
|
|
30
|
+
"run",
|
|
31
|
+
"link",
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
interface NodeLinks {
|
|
35
|
+
isc?: number;
|
|
36
|
+
commitment?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface EventBase {
|
|
40
|
+
id: string;
|
|
41
|
+
ts: string;
|
|
42
|
+
scope: string;
|
|
43
|
+
node: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type WorkEvent = EventBase &
|
|
47
|
+
(
|
|
48
|
+
| { type: "open"; title: string; parent: string | null; links?: NodeLinks }
|
|
49
|
+
| { type: "enter" }
|
|
50
|
+
| { type: "park"; stoppedAt?: string }
|
|
51
|
+
| { type: "wait"; on: string }
|
|
52
|
+
| { type: "done"; summary?: string }
|
|
53
|
+
| { type: "stopped"; text: string }
|
|
54
|
+
| { type: "decide"; text: string; why?: string }
|
|
55
|
+
| { type: "attach"; kind: ArtifactKind; ref: string; label?: string }
|
|
56
|
+
| { type: "follow-up"; text: string; due?: string }
|
|
57
|
+
| { type: "git-cursor"; branch: string; head: string }
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
type Payload<T extends WorkEvent["type"]> = Omit<
|
|
61
|
+
Extract<WorkEvent, { type: T }>,
|
|
62
|
+
keyof EventBase | "type"
|
|
63
|
+
>;
|
|
64
|
+
|
|
65
|
+
interface Decision {
|
|
66
|
+
ts: string;
|
|
67
|
+
text: string;
|
|
68
|
+
why?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface Artifact {
|
|
72
|
+
ts: string;
|
|
73
|
+
kind: ArtifactKind;
|
|
74
|
+
ref: string;
|
|
75
|
+
label?: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface FollowUp {
|
|
79
|
+
ts: string;
|
|
80
|
+
text: string;
|
|
81
|
+
due?: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface WorkNode {
|
|
85
|
+
id: string;
|
|
86
|
+
title: string;
|
|
87
|
+
parent: string | null;
|
|
88
|
+
status: NodeStatus;
|
|
89
|
+
stoppedAt: string | null;
|
|
90
|
+
waitingOn: string | null;
|
|
91
|
+
summary: string | null;
|
|
92
|
+
decisions: Decision[];
|
|
93
|
+
artifacts: Artifact[];
|
|
94
|
+
followUps: FollowUp[];
|
|
95
|
+
links: NodeLinks;
|
|
96
|
+
opened: string;
|
|
97
|
+
updated: string;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
interface GitCursor {
|
|
101
|
+
branch: string;
|
|
102
|
+
head: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface ScopeTree {
|
|
106
|
+
scope: string;
|
|
107
|
+
active: string | null;
|
|
108
|
+
nodes: Map<string, WorkNode>;
|
|
109
|
+
gitCursor: GitCursor | null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function workTreeFile(): string {
|
|
113
|
+
return resolve(ensureDir(resolve(paths.memory(), "work-tree")), "events.jsonl");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** A registered project is its own scope wherever in it the agent stands; anything else is its folder. */
|
|
117
|
+
export function scopeOf(cwd: string): string {
|
|
118
|
+
return resolveProjectFromCwd(cwd, readAllProjects())?.name ?? resolve(cwd);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function newNodeId(
|
|
122
|
+
now: number = Date.now(),
|
|
123
|
+
random: () => number = Math.random
|
|
124
|
+
): string {
|
|
125
|
+
return now.toString(36).slice(-5) + random().toString(36).slice(2, 5);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function parseLine(line: string): WorkEvent[] {
|
|
129
|
+
try {
|
|
130
|
+
return [JSON.parse(line) as WorkEvent];
|
|
131
|
+
} catch {
|
|
132
|
+
return [];
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Ordered by time, so the union an import merges in replays the same as one machine's log. */
|
|
137
|
+
export function readEvents(file: string = workTreeFile()): WorkEvent[] {
|
|
138
|
+
if (!existsSync(file)) return [];
|
|
139
|
+
return readFileSync(file, "utf-8")
|
|
140
|
+
.split("\n")
|
|
141
|
+
.filter((line) => line.trim())
|
|
142
|
+
.flatMap(parseLine)
|
|
143
|
+
.sort((a, b) => a.ts.localeCompare(b.ts));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function appendEvent<T extends WorkEvent["type"]>(
|
|
147
|
+
scope: string,
|
|
148
|
+
node: string,
|
|
149
|
+
type: T,
|
|
150
|
+
payload: Payload<T>,
|
|
151
|
+
file: string = workTreeFile(),
|
|
152
|
+
now: Date = new Date()
|
|
153
|
+
): WorkEvent {
|
|
154
|
+
const event = {
|
|
155
|
+
id: newNodeId(now.getTime()),
|
|
156
|
+
ts: now.toISOString(),
|
|
157
|
+
scope,
|
|
158
|
+
node,
|
|
159
|
+
type,
|
|
160
|
+
...payload,
|
|
161
|
+
} as WorkEvent;
|
|
162
|
+
appendFileSync(file, `${JSON.stringify(event)}\n`, "utf-8");
|
|
163
|
+
return event;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function emptyNode(
|
|
167
|
+
id: string,
|
|
168
|
+
title: string,
|
|
169
|
+
parent: string | null,
|
|
170
|
+
ts: string
|
|
171
|
+
): WorkNode {
|
|
172
|
+
return {
|
|
173
|
+
id,
|
|
174
|
+
title,
|
|
175
|
+
parent,
|
|
176
|
+
status: "active",
|
|
177
|
+
stoppedAt: null,
|
|
178
|
+
waitingOn: null,
|
|
179
|
+
summary: null,
|
|
180
|
+
decisions: [],
|
|
181
|
+
artifacts: [],
|
|
182
|
+
followUps: [],
|
|
183
|
+
links: {},
|
|
184
|
+
opened: ts,
|
|
185
|
+
updated: ts,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function parkActive(tree: ScopeTree): void {
|
|
190
|
+
const current = tree.active ? tree.nodes.get(tree.active) : undefined;
|
|
191
|
+
if (current?.status === "active") current.status = "parked";
|
|
192
|
+
tree.active = null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function activate(tree: ScopeTree, node: WorkNode): void {
|
|
196
|
+
if (tree.active !== node.id) parkActive(tree);
|
|
197
|
+
node.status = "active";
|
|
198
|
+
node.waitingOn = null;
|
|
199
|
+
tree.active = node.id;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Finishing a detour hands the work back to the nearest unfinished ancestor. */
|
|
203
|
+
function returnToParent(tree: ScopeTree, node: WorkNode): void {
|
|
204
|
+
let parent = node.parent ? tree.nodes.get(node.parent) : undefined;
|
|
205
|
+
while (parent && parent.status === "done")
|
|
206
|
+
parent = parent.parent ? tree.nodes.get(parent.parent) : undefined;
|
|
207
|
+
if (parent) activate(tree, parent);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function applyToNode(tree: ScopeTree, node: WorkNode, event: WorkEvent): void {
|
|
211
|
+
switch (event.type) {
|
|
212
|
+
case "enter":
|
|
213
|
+
activate(tree, node);
|
|
214
|
+
break;
|
|
215
|
+
case "park":
|
|
216
|
+
if (event.stoppedAt) node.stoppedAt = event.stoppedAt;
|
|
217
|
+
node.status = "parked";
|
|
218
|
+
if (tree.active === node.id) tree.active = null;
|
|
219
|
+
break;
|
|
220
|
+
case "wait":
|
|
221
|
+
node.status = "waiting";
|
|
222
|
+
node.waitingOn = event.on;
|
|
223
|
+
if (tree.active === node.id) tree.active = null;
|
|
224
|
+
break;
|
|
225
|
+
case "done": {
|
|
226
|
+
const wasActive = tree.active === node.id;
|
|
227
|
+
node.status = "done";
|
|
228
|
+
node.summary = event.summary ?? node.summary;
|
|
229
|
+
if (wasActive) {
|
|
230
|
+
tree.active = null;
|
|
231
|
+
returnToParent(tree, node);
|
|
232
|
+
}
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
case "stopped":
|
|
236
|
+
node.stoppedAt = event.text;
|
|
237
|
+
break;
|
|
238
|
+
case "decide":
|
|
239
|
+
node.decisions.push({ ts: event.ts, text: event.text, why: event.why });
|
|
240
|
+
break;
|
|
241
|
+
case "attach":
|
|
242
|
+
node.artifacts.push({
|
|
243
|
+
ts: event.ts,
|
|
244
|
+
kind: event.kind,
|
|
245
|
+
ref: event.ref,
|
|
246
|
+
label: event.label,
|
|
247
|
+
});
|
|
248
|
+
break;
|
|
249
|
+
case "follow-up":
|
|
250
|
+
node.followUps.push({ ts: event.ts, text: event.text, due: event.due });
|
|
251
|
+
break;
|
|
252
|
+
}
|
|
253
|
+
node.updated = event.ts;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function apply(tree: ScopeTree, event: WorkEvent): void {
|
|
257
|
+
if (event.type === "git-cursor") {
|
|
258
|
+
tree.gitCursor = { branch: event.branch, head: event.head };
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
if (event.type === "open") {
|
|
262
|
+
const node = emptyNode(event.node, event.title, event.parent, event.ts);
|
|
263
|
+
node.links = event.links ?? {};
|
|
264
|
+
tree.nodes.set(node.id, node);
|
|
265
|
+
activate(tree, node);
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
const node = tree.nodes.get(event.node);
|
|
269
|
+
if (node) applyToNode(tree, node, event);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function buildTree(scope: string, events: WorkEvent[]): ScopeTree {
|
|
273
|
+
const tree: ScopeTree = { scope, active: null, nodes: new Map(), gitCursor: null };
|
|
274
|
+
for (const event of events) if (event.scope === scope) apply(tree, event);
|
|
275
|
+
return tree;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export function readTree(scope: string, file: string = workTreeFile()): ScopeTree {
|
|
279
|
+
return buildTree(scope, readEvents(file));
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** From the root down to the active node: the detours the user is inside of. */
|
|
283
|
+
export function activePath(tree: ScopeTree): WorkNode[] {
|
|
284
|
+
const path: WorkNode[] = [];
|
|
285
|
+
let node = tree.active ? tree.nodes.get(tree.active) : undefined;
|
|
286
|
+
while (node) {
|
|
287
|
+
path.unshift(node);
|
|
288
|
+
node = node.parent ? tree.nodes.get(node.parent) : undefined;
|
|
289
|
+
}
|
|
290
|
+
return path;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function byRecent(a: WorkNode, b: WorkNode): number {
|
|
294
|
+
return b.updated.localeCompare(a.updated);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export function nodesWithStatus(tree: ScopeTree, status: NodeStatus): WorkNode[] {
|
|
298
|
+
return [...tree.nodes.values()].filter((n) => n.status === status).sort(byRecent);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** An id, or any unambiguous prefix of one. */
|
|
302
|
+
export function findNode(tree: ScopeTree, idOrPrefix: string): WorkNode | null {
|
|
303
|
+
const exact = tree.nodes.get(idOrPrefix);
|
|
304
|
+
if (exact) return exact;
|
|
305
|
+
const matches = [...tree.nodes.values()].filter((n) => n.id.startsWith(idOrPrefix));
|
|
306
|
+
return matches.length === 1 ? matches[0] : null;
|
|
307
|
+
}
|
package/src/tools/lib/command.ts
CHANGED
|
@@ -78,7 +78,15 @@ export async function runCommand(
|
|
|
78
78
|
: runLeaf(command, args, path);
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
function startsWithFallbackOption(command: Group, args: string[]): boolean {
|
|
82
|
+
const first = args[0];
|
|
83
|
+
if (!command.fallback || first === undefined || !first.startsWith("-")) return false;
|
|
84
|
+
return !HELP_FLAGS.has(first) && !command.aliases?.[first];
|
|
85
|
+
}
|
|
86
|
+
|
|
81
87
|
async function runGroup(command: Group, args: string[], path: string[]): Promise<number> {
|
|
88
|
+
if (command.fallback && startsWithFallbackOption(command, args))
|
|
89
|
+
return runGroup(command, [command.fallback, ...args], path);
|
|
82
90
|
const [first, ...rest] = args;
|
|
83
91
|
const name = first === undefined ? command.fallback : first;
|
|
84
92
|
if (name === undefined || name === "help" || HELP_FLAGS.has(name)) {
|