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.
@@ -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
+ }
@@ -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)) {