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