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