@aefree/pi-plastic 0.4.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.
Files changed (61) hide show
  1. package/CHANGELOG.md +109 -0
  2. package/CONTRIBUTING.md +88 -0
  3. package/LICENSE +21 -0
  4. package/README.md +181 -0
  5. package/SECURITY.md +31 -0
  6. package/evals/tool-loading/README.md +38 -0
  7. package/evals/tool-loading/RESULTS.md +57 -0
  8. package/evals/tool-loading/baseline.json +34 -0
  9. package/evals/tool-loading/cases.json +70 -0
  10. package/evals/tool-loading/config.json +19 -0
  11. package/evals/tool-loading/mutation-guard.ts +30 -0
  12. package/evals/tool-loading/provider-capture.ts +22 -0
  13. package/evals/tool-loading/results/.gitignore +3 -0
  14. package/evals/tool-loading/run-eval.ts +372 -0
  15. package/extensions/bash-cm-diff-guard.ts +49 -0
  16. package/extensions/bash-cm-merge-guard.ts +53 -0
  17. package/extensions/plastic-branch-status.ts +285 -0
  18. package/extensions/repository-search-provider.ts +66 -0
  19. package/extensions/shared/bash-command-guards.ts +67 -0
  20. package/index.ts +777 -0
  21. package/package.json +76 -0
  22. package/skills/_shared/references/conventional-commits.md +198 -0
  23. package/skills/using-plastic/SKILL.md +50 -0
  24. package/skills/using-plastic/references/branch-management.md +78 -0
  25. package/skills/using-plastic/references/changeset-operations.md +96 -0
  26. package/skills/using-plastic/references/code-review-creation.md +30 -0
  27. package/skills/using-plastic/references/integration.md +47 -0
  28. package/skills/using-plastic/references/quick-reference.md +32 -0
  29. package/skills/using-plastic/references/resources.md +7 -0
  30. package/skills/using-plastic/references/reviewing-changes.md +71 -0
  31. package/skills/using-plastic/references/shelving.md +25 -0
  32. package/skills/using-plastic/references/troubleshooting.md +115 -0
  33. package/skills/using-plastic/references/workspaces.md +33 -0
  34. package/src/pi-tool-compat.ts +56 -0
  35. package/src/plastic-core.ts +4204 -0
  36. package/src/plastic-tool-loading.ts +234 -0
  37. package/src/plastic-workspace.ts +176 -0
  38. package/src/repository-search-provider.ts +64 -0
  39. package/tests/bash-cm-diff-guard-validation.ts +55 -0
  40. package/tests/fixtures/plastic.selector +3 -0
  41. package/tests/fixtures/status-compact.txt +1 -0
  42. package/tests/fixtures/status-normal.txt +1 -0
  43. package/tests/packed-copy-owner-conflict.test.ts +28 -0
  44. package/tests/pi-tool-harness.ts +116 -0
  45. package/tests/plastic-branch-create-live.test.ts +78 -0
  46. package/tests/plastic-branch-query.test.ts +26 -0
  47. package/tests/plastic-branch-status.test.ts +241 -0
  48. package/tests/plastic-checkin-path-resolution.ts +219 -0
  49. package/tests/plastic-constrained-sampling.test.ts +77 -0
  50. package/tests/plastic-diff-workflow.test.ts +86 -0
  51. package/tests/plastic-dynamic-tool-loading.test.ts +224 -0
  52. package/tests/plastic-extension-registration.test.ts +34 -0
  53. package/tests/plastic-live-smoke.test.ts +37 -0
  54. package/tests/plastic-mutation-execution.test.ts +61 -0
  55. package/tests/plastic-patch-command.test.ts +118 -0
  56. package/tests/plastic-process-lifecycle.test.ts +148 -0
  57. package/tests/plastic-tool-rendering.test.ts +16 -0
  58. package/tests/plastic-tool-validation.ts +153 -0
  59. package/tests/plastic-workspace.test.ts +62 -0
  60. package/tests/repository-search-provider-registration.test.ts +37 -0
  61. package/tests/repository-search-provider.test.ts +27 -0
@@ -0,0 +1,116 @@
1
+ import { fileURLToPath } from "node:url";
2
+
3
+ export type ToolSourceInfo = {
4
+ path: string;
5
+ source: string;
6
+ scope: string;
7
+ origin: string;
8
+ baseDir?: string;
9
+ };
10
+
11
+ export type RegisteredTool = {
12
+ name: string;
13
+ label?: string;
14
+ description?: string;
15
+ promptSnippet?: string;
16
+ promptGuidelines?: string[];
17
+ sourceInfo?: ToolSourceInfo;
18
+ parameters?: unknown;
19
+ constrainedSampling?: false | {
20
+ type: "json_schema";
21
+ strict: "prefer" | "require";
22
+ } | {
23
+ type: "grammar";
24
+ variants: {
25
+ openai_lark?: string;
26
+ openai_regex?: string;
27
+ };
28
+ };
29
+ prepareArguments?: (args: unknown) => Record<string, unknown>;
30
+ execute: (...args: any[]) => Promise<any> | any;
31
+ };
32
+
33
+ type SessionStartHandler = (event: unknown, ctx: { sessionManager: { getBranch: () => unknown[] } }) => Promise<void> | void;
34
+
35
+ const DEFAULT_EXTENSION_SOURCE: ToolSourceInfo = {
36
+ path: fileURLToPath(new URL("../index.ts", import.meta.url)),
37
+ source: "extension",
38
+ scope: "project",
39
+ origin: "package",
40
+ };
41
+
42
+ export class PiToolHarness {
43
+ readonly registry = new Map<string, RegisteredTool>();
44
+ readonly setActiveToolsCalls: string[][] = [];
45
+ private readonly sessionStartHandlers: SessionStartHandler[] = [];
46
+ private readonly sourceInfoAvailable: boolean;
47
+ private readonly extensionSourceInfo: ToolSourceInfo;
48
+ private activeTools: string[];
49
+ private branchEntries: unknown[];
50
+
51
+ constructor(options: {
52
+ activeTools?: string[];
53
+ branchEntries?: unknown[];
54
+ foreignTools?: RegisteredTool[];
55
+ sourceInfoAvailable?: boolean;
56
+ extensionSourceInfo?: ToolSourceInfo;
57
+ } = {}) {
58
+ this.activeTools = [...(options.activeTools ?? [])];
59
+ this.branchEntries = [...(options.branchEntries ?? [])];
60
+ // Default to Pi 0.82 behavior; individual compatibility tests explicitly
61
+ // opt out to model older runtimes without canonical sourceInfo.
62
+ this.sourceInfoAvailable = options.sourceInfoAvailable ?? true;
63
+ this.extensionSourceInfo = options.extensionSourceInfo ?? DEFAULT_EXTENSION_SOURCE;
64
+ for (const tool of options.foreignTools ?? []) this.registry.set(tool.name, { ...tool });
65
+ }
66
+
67
+ readonly api = {
68
+ registerTool: (tool: RegisteredTool) => {
69
+ // Pi keeps the first effective definition for a colliding extension name.
70
+ if (!this.registry.has(tool.name)) {
71
+ this.registry.set(tool.name, this.sourceInfoAvailable ? { ...tool, sourceInfo: this.extensionSourceInfo } : { ...tool });
72
+ }
73
+ },
74
+ on: (event: string, handler: SessionStartHandler) => {
75
+ if (event === "session_start") this.sessionStartHandlers.push(handler);
76
+ },
77
+ getActiveTools: () => [...this.activeTools],
78
+ getAllTools: () => [...this.registry.values()],
79
+ setActiveTools: (names: string[]) => {
80
+ this.activeTools = [...new Set(names)];
81
+ this.setActiveToolsCalls.push([...this.activeTools]);
82
+ },
83
+ };
84
+
85
+ async load(modulePath = "../index.ts"): Promise<void> {
86
+ const extensionModule = await import(modulePath);
87
+ const extension = extensionModule.default as ((pi: any) => void) | undefined;
88
+ if (typeof extension !== "function") throw new Error(`Extension module '${modulePath}' has no default function export.`);
89
+ extension(this.api);
90
+ }
91
+
92
+ async startSession(branchEntries = this.branchEntries): Promise<void> {
93
+ this.branchEntries = [...branchEntries];
94
+ const ctx = { sessionManager: { getBranch: () => [...this.branchEntries] } };
95
+ for (const handler of this.sessionStartHandlers) await handler({ reason: "startup" }, ctx);
96
+ }
97
+
98
+ getActiveTools(): string[] {
99
+ return [...this.activeTools];
100
+ }
101
+ }
102
+
103
+ export async function loadRegisteredTools(modulePath = "../index.ts"): Promise<Map<string, RegisteredTool>> {
104
+ const harness = new PiToolHarness();
105
+ await harness.load(modulePath);
106
+ return harness.registry;
107
+ }
108
+
109
+ export async function invokeRegisteredTool(name: string, args: Record<string, unknown>, cwd = process.cwd(), modulePath = "../index.ts"): Promise<string> {
110
+ const tools = await loadRegisteredTools(modulePath);
111
+ const tool = tools.get(name);
112
+ if (!tool) throw new Error(`Tool '${name}' is not registered.`);
113
+ const result = await tool.execute("test-tool-call", args, undefined, undefined, { cwd });
114
+ const textParts = Array.isArray(result?.content) ? result.content.filter((entry: any) => entry?.type === "text") : [];
115
+ return textParts.map((entry: any) => String(entry.text ?? "")).join("\n");
116
+ }
@@ -0,0 +1,78 @@
1
+ import assert from "node:assert/strict";
2
+ import { invokeRegisteredTool } from "./pi-tool-harness.ts";
3
+
4
+ type ToolEnvelope = {
5
+ ok?: boolean;
6
+ data?: Record<string, unknown>;
7
+ };
8
+
9
+ const parseToolEnvelope = (text: string): ToolEnvelope => {
10
+ const fencedJson = text.match(/```json\s*([\s\S]*?)\s*```/);
11
+ assert(fencedJson, "Expected a structured JSON tool result.");
12
+ return JSON.parse(fencedJson[1]) as ToolEnvelope;
13
+ };
14
+
15
+ const workspace = process.env.PI_PLASTIC_TEST_WORKSPACE?.trim();
16
+ const mutationAllowed = process.env.PI_PLASTIC_ALLOW_MUTATION_TESTS?.trim().toLowerCase() === "true";
17
+
18
+ if (!workspace || !mutationAllowed) {
19
+ console.log("SKIP: set PI_PLASTIC_TEST_WORKSPACE and PI_PLASTIC_ALLOW_MUTATION_TESTS=true to run branch mutation tests");
20
+ process.exit(0);
21
+ }
22
+
23
+ const status = parseToolEnvelope(await invokeRegisteredTool("plastic_status", { short: true, format: "json" }, workspace));
24
+ assert.equal(status.ok, true, "Expected Plastic status to succeed.");
25
+ const summary = status.data?.summary as Record<string, unknown> | undefined;
26
+ const mergeState = status.data?.mergeState as Record<string, unknown> | undefined;
27
+ assert.equal(summary?.totalPending, 0, "Live branch tests require a clean workspace.");
28
+ assert.equal(mergeState?.hasMergeInProgress, false, "Live branch tests require no merge in progress.");
29
+
30
+ const currentResult = parseToolEnvelope(await invokeRegisteredTool("plastic_currentBranch", { format: "json" }, workspace));
31
+ const loadedBranch = currentResult.data?.branch;
32
+ assert.equal(typeof loadedBranch, "string", "Expected a loaded Plastic branch.");
33
+
34
+ const suffix = `${process.pid}-${Date.now().toString(36)}`;
35
+ const parent = `${loadedBranch}/pi-live-parent-${suffix}`;
36
+ const childLeaf = `child-${suffix}`;
37
+ const child = `${parent}/${childLeaf}`;
38
+ const blockedRoot = `/pi-live-root-${suffix}`;
39
+
40
+ const exists = async (branch: string): Promise<boolean> =>
41
+ (await invokeRegisteredTool("plastic_branchExists", { branch }, workspace)).trim() === "true";
42
+
43
+ assert.equal(await exists(parent), false, `Temporary parent already exists: ${parent}`);
44
+ assert.equal(await exists(child), false, `Temporary child already exists: ${child}`);
45
+ assert.equal(await exists(blockedRoot), false, `Temporary root already exists: ${blockedRoot}`);
46
+
47
+ try {
48
+ await invokeRegisteredTool("plastic_branchCreate", { branch: parent, comment: "pi-plastic live parent test" }, workspace);
49
+ assert.equal(await exists(parent), true, "Expected full hierarchical branch creation to succeed.");
50
+
51
+ await invokeRegisteredTool("plastic_branchCreate", {
52
+ branch: childLeaf,
53
+ parent,
54
+ comment: "pi-plastic explicit-parent live test",
55
+ }, workspace);
56
+ assert.equal(await exists(child), true, "Expected creation beneath an explicit non-loaded parent to succeed.");
57
+
58
+ let rootGuarded = false;
59
+ try {
60
+ const output = await invokeRegisteredTool("plastic_branchCreate", { branch: blockedRoot }, workspace);
61
+ rootGuarded = /Refusing to create top-level branch/.test(output);
62
+ } catch (error) {
63
+ rootGuarded = /Refusing to create top-level branch/.test(error instanceof Error ? error.message : String(error));
64
+ }
65
+ assert.equal(rootGuarded, true, "Expected top-level creation to be rejected without allowRootBranch.");
66
+ assert.equal(await exists(blockedRoot), false, "Rejected top-level branch must not be created.");
67
+
68
+ const branchAfter = parseToolEnvelope(await invokeRegisteredTool("plastic_currentBranch", { format: "json" }, workspace));
69
+ assert.equal(branchAfter.data?.branch, loadedBranch, "Branch creation must not switch the workspace.");
70
+ } finally {
71
+ if (await exists(child)) await invokeRegisteredTool("plastic_branchDelete", { branch: child }, workspace);
72
+ if (await exists(parent)) await invokeRegisteredTool("plastic_branchDelete", { branch: parent }, workspace);
73
+ }
74
+
75
+ assert.equal(await exists(child), false, "Temporary child branch cleanup failed.");
76
+ assert.equal(await exists(parent), false, "Temporary parent branch cleanup failed.");
77
+ assert.equal(await exists(blockedRoot), false, "Top-level guard cleanup invariant failed.");
78
+ console.log("PASS: live hierarchical branch creation, explicit-parent behavior, top-level guard, and cleanup");
@@ -0,0 +1,26 @@
1
+ import assert from "node:assert/strict";
2
+ import { __plasticBranchInternals } from "../src/plastic-core.ts";
3
+
4
+ const { getBranchLeafName, resolveBranchCreationTarget } = __plasticBranchInternals;
5
+
6
+ assert.equal(getBranchLeafName("/main/feature/test"), "test");
7
+ assert.equal(getBranchLeafName("br:/main/feature/test"), "test");
8
+ assert.equal(getBranchLeafName("br:/main/feature/test@repo@server"), "test");
9
+ assert.equal(getBranchLeafName("test"), "test");
10
+
11
+ assert.equal(resolveBranchCreationTarget("foo", "/main"), "/main/foo");
12
+ assert.equal(resolveBranchCreationTarget("feature/foo", "/main"), "/main/feature/foo");
13
+ assert.equal(resolveBranchCreationTarget("foo", "/release"), "/release/foo");
14
+ assert.equal(resolveBranchCreationTarget("/release/foo"), "/release/foo");
15
+ assert.equal(resolveBranchCreationTarget("br:/release/foo@repo@server"), "br:/release/foo@repo@server");
16
+ assert.equal(resolveBranchCreationTarget("/foo", undefined, true), "/foo");
17
+ assert.throws(
18
+ () => resolveBranchCreationTarget("/foo"),
19
+ /Refusing to create top-level branch \/foo.*allowRootBranch=true/,
20
+ );
21
+ assert.throws(
22
+ () => resolveBranchCreationTarget("foo"),
23
+ /relative branch name requires a parent branch/,
24
+ );
25
+
26
+ console.log("PASS: Plastic branch query normalization tests passed");
@@ -0,0 +1,241 @@
1
+ import assert from "node:assert/strict";
2
+ import { readFile } from "node:fs/promises";
3
+ import { createPlasticBranchStatusExtension } from "../extensions/plastic-branch-status.ts";
4
+
5
+ type Handler = (event: any, ctx: any) => Promise<void> | void;
6
+ type FakeTimer = { callback: () => void; delay: number; cleared: boolean };
7
+
8
+ class FooterHarness {
9
+ handlers = new Map<string, Handler[]>();
10
+ statuses = new Map<string, string>();
11
+ statusCalls: Array<[string, string | undefined]> = [];
12
+ setFooterCalls = 0;
13
+ readonly ctx = {
14
+ cwd: "/workspace/nested/project",
15
+ hasUI: true,
16
+ ui: {
17
+ theme: { fg: (color: string, text: string) => `<${color}>${text}</${color}>` },
18
+ setStatus: (key: string, value: string | undefined) => {
19
+ this.statusCalls.push([key, value]);
20
+ if (value === undefined) this.statuses.delete(key);
21
+ else this.statuses.set(key, value);
22
+ },
23
+ setFooter: () => { this.setFooterCalls++; },
24
+ },
25
+ };
26
+ readonly api = {
27
+ on: (event: string, handler: Handler) => {
28
+ const handlers = this.handlers.get(event) ?? [];
29
+ handlers.push(handler);
30
+ this.handlers.set(event, handlers);
31
+ },
32
+ exec: async () => ({ stdout: "", stderr: "", code: 0, killed: false }),
33
+ };
34
+
35
+ async emit(event: string, payload: any = {}): Promise<void> {
36
+ for (const handler of this.handlers.get(event) ?? []) await handler(payload, this.ctx);
37
+ }
38
+ }
39
+
40
+ const workspace = (root: string) => ({
41
+ kind: "found" as const,
42
+ value: { root, plasticDir: `${root}/.plastic`, markerPath: `${root}/.plastic/plastic.workspace` },
43
+ });
44
+
45
+ const timers: FakeTimer[] = [];
46
+ const schedule = (callback: () => void, delay: number): any => {
47
+ const timer = { callback, delay, cleared: false };
48
+ timers.push(timer);
49
+ return timer;
50
+ };
51
+ const clear = (timer: any): void => { timer.cleared = true; };
52
+ const runTimers = async (delay: number): Promise<void> => {
53
+ const pending = timers.filter((timer) => !timer.cleared && timer.delay === delay);
54
+ for (const timer of pending) {
55
+ timer.cleared = true;
56
+ timer.callback();
57
+ }
58
+ await new Promise((resolve) => setImmediate(resolve));
59
+ };
60
+
61
+ {
62
+ timers.length = 0;
63
+ const harness = new FooterHarness();
64
+ harness.statuses.set("foreign-status", "keep me");
65
+ let confirmation = "/main/from-cm@repo@server\n";
66
+ let confirmCount = 0;
67
+ let watcherClosed = 0;
68
+ let watcherChange: (() => void) | undefined;
69
+ let watcherError: (() => void) | undefined;
70
+
71
+ createPlasticBranchStatusExtension({
72
+ discoverWorkspace: async (cwd) => cwd.startsWith("/sibling") ? workspace("/sibling") : workspace("/workspace"),
73
+ readSelector: async () => "repository repo@server\n path /\n smartbranch /main/from-selector\n",
74
+ watchDirectory: (_path, onChange, onError) => {
75
+ watcherChange = onChange;
76
+ watcherError = onError;
77
+ return { close: () => { watcherClosed++; } };
78
+ },
79
+ confirmBranch: async () => { confirmCount++; return confirmation; },
80
+ setTimeout: schedule,
81
+ clearTimeout: clear,
82
+ platform: "linux",
83
+ })(harness.api as any);
84
+
85
+ await harness.emit("session_start", { reason: "startup" });
86
+ assert.match(harness.statuses.get("plastic-branch") ?? "", /\/main\/from-cm/);
87
+ assert.equal(harness.statuses.get("foreign-status"), "keep me");
88
+ assert.equal(harness.setFooterCalls, 0);
89
+ assert.equal(confirmCount, 1);
90
+
91
+ confirmation = "/main/after-selector-replacement@repo@server\n";
92
+ watcherChange?.();
93
+ watcherChange?.();
94
+ await runTimers(150);
95
+ assert.equal(confirmCount, 2, "coalesced watcher events should run one refresh");
96
+ assert.match(harness.statuses.get("plastic-branch") ?? "", /after-selector-replacement/);
97
+ assert.ok(watcherClosed >= 1, "workspace watcher should be rebound after a directory event");
98
+
99
+ await harness.emit("tool_result", { toolCallId: "same", toolName: "plastic_switchBranch", input: { workdir: "." }, isError: false });
100
+ await harness.emit("tool_execution_end", { toolCallId: "same", toolName: "plastic_switchBranch", isError: false });
101
+ await runTimers(150);
102
+ assert.equal(confirmCount, 3, "same-workspace Plastic tools should refresh");
103
+
104
+ await harness.emit("tool_result", { toolCallId: "sibling", toolName: "plastic_status", input: { workdir: "/sibling" }, isError: false });
105
+ await harness.emit("tool_execution_end", { toolCallId: "sibling", toolName: "plastic_status", isError: false });
106
+ await runTimers(150);
107
+ assert.equal(confirmCount, 3, "sibling-workspace tools must not refresh this footer");
108
+
109
+ await harness.emit("tool_result", { toolCallId: "sibling-alias", toolName: "plastic_status", input: { cwd: "/sibling", workdir: "/sibling" }, isError: false });
110
+ await harness.emit("tool_execution_end", { toolCallId: "sibling-alias", toolName: "plastic_status", isError: false });
111
+ await runTimers(150);
112
+ assert.equal(confirmCount, 3, "normalized effective workdir should control sibling filtering");
113
+
114
+ watcherError?.();
115
+ await runTimers(150);
116
+ assert.equal(confirmCount, 4, "watcher errors should trigger bounded revalidation");
117
+
118
+ await harness.emit("session_shutdown", { reason: "reload" });
119
+ assert.equal(harness.statuses.has("plastic-branch"), false);
120
+ assert.equal(harness.statuses.get("foreign-status"), "keep me");
121
+ }
122
+
123
+ {
124
+ timers.length = 0;
125
+ const harness = new FooterHarness();
126
+ createPlasticBranchStatusExtension({
127
+ discoverWorkspace: async () => workspace("/workspace"),
128
+ readSelector: async () => "smartbranch /main/selector-only\n",
129
+ watchDirectory: () => ({ close() {} }),
130
+ confirmBranch: async () => { throw new Error("cm unavailable"); },
131
+ setTimeout: schedule,
132
+ clearTimeout: clear,
133
+ platform: "linux",
134
+ })(harness.api as any);
135
+ await harness.emit("session_start");
136
+ assert.match(harness.statuses.get("plastic-branch") ?? "", /selector-only/, "selector fallback should survive cm failure");
137
+ }
138
+
139
+ {
140
+ timers.length = 0;
141
+ const harness = new FooterHarness();
142
+ let aborted = false;
143
+ createPlasticBranchStatusExtension({
144
+ discoverWorkspace: async () => workspace("/workspace"),
145
+ readSelector: async () => "smartbranch \n",
146
+ watchDirectory: () => ({ close() {} }),
147
+ confirmBranch: async (_cwd, signal) => new Promise((_resolve, reject) => {
148
+ signal.addEventListener("abort", () => { aborted = true; reject(new Error("aborted")); }, { once: true });
149
+ }),
150
+ setTimeout: schedule,
151
+ clearTimeout: clear,
152
+ platform: "win32",
153
+ })(harness.api as any);
154
+ const startup = harness.emit("session_start");
155
+ await new Promise((resolve) => setImmediate(resolve));
156
+ assert.match(harness.statuses.get("plastic-branch") ?? "", /unavailable/);
157
+ await harness.emit("session_shutdown", { reason: "quit" });
158
+ await startup;
159
+ assert.equal(aborted, true, "shutdown should abort in-flight cm confirmation");
160
+ assert.equal(harness.statuses.has("plastic-branch"), false);
161
+ }
162
+
163
+ {
164
+ timers.length = 0;
165
+ const harness = new FooterHarness();
166
+ let markerPresent = true;
167
+ const watched: Array<{ path: string; change: () => void; closed: boolean }> = [];
168
+ createPlasticBranchStatusExtension({
169
+ discoverWorkspace: async () => markerPresent ? workspace("/workspace") : { kind: "not_found" },
170
+ readSelector: async () => "smartbranch /main/recreated\n",
171
+ watchDirectory: (path, onChange) => {
172
+ const handle = { path, change: onChange, closed: false };
173
+ watched.push(handle);
174
+ return { close: () => { handle.closed = true; } };
175
+ },
176
+ confirmBranch: async () => "/main/recreated@repo@server\n",
177
+ setTimeout: schedule,
178
+ clearTimeout: clear,
179
+ platform: "linux",
180
+ })(harness.api as any);
181
+
182
+ await harness.emit("session_start");
183
+ markerPresent = false;
184
+ watched.find((entry) => entry.path.endsWith("/.plastic") && !entry.closed)?.change();
185
+ await runTimers(150);
186
+ assert.equal(harness.statuses.has("plastic-branch"), false);
187
+ const recoveryWatcher = watched.find((entry) => entry.path === "/workspace" && !entry.closed);
188
+ assert.ok(recoveryWatcher, "workspace root should remain watched while .plastic is temporarily absent");
189
+
190
+ markerPresent = true;
191
+ recoveryWatcher?.change();
192
+ await runTimers(150);
193
+ assert.match(harness.statuses.get("plastic-branch") ?? "", /recreated/);
194
+ assert.ok(watched.some((entry) => entry.path.endsWith("/.plastic") && !entry.closed), "recreated .plastic directory should be rebound");
195
+ await harness.emit("session_shutdown");
196
+ assert.ok(watched.every((entry) => entry.closed), "all primary and recovery watchers should close on shutdown");
197
+ }
198
+
199
+ {
200
+ timers.length = 0;
201
+ const harness = new FooterHarness();
202
+ let innerPresent = true;
203
+ const watched: Array<{ path: string; change: () => void; closed: boolean }> = [];
204
+ createPlasticBranchStatusExtension({
205
+ discoverWorkspace: async () => innerPresent ? workspace("/workspace/inner") : workspace("/workspace"),
206
+ readSelector: async (path) => path.includes("/inner/") ? "smartbranch /main/inner\n" : "smartbranch /main/outer\n",
207
+ watchDirectory: (path, onChange) => {
208
+ const handle = { path, change: onChange, closed: false };
209
+ watched.push(handle);
210
+ return { close: () => { handle.closed = true; } };
211
+ },
212
+ confirmBranch: async (cwd) => cwd.endsWith("/inner") ? "/main/inner@repo@server\n" : "/main/outer@repo@server\n",
213
+ setTimeout: schedule,
214
+ clearTimeout: clear,
215
+ platform: "linux",
216
+ })(harness.api as any);
217
+
218
+ await harness.emit("session_start");
219
+ innerPresent = false;
220
+ watched.find((entry) => entry.path === "/workspace/inner/.plastic" && !entry.closed)?.change();
221
+ await runTimers(150);
222
+ assert.match(harness.statuses.get("plastic-branch") ?? "", /\/main\/outer/);
223
+ const nestedRecovery = watched.find((entry) => entry.path === "/workspace/inner" && !entry.closed);
224
+ assert.ok(nestedRecovery, "former nested workspace root should remain watched during outer fallback");
225
+
226
+ innerPresent = true;
227
+ nestedRecovery?.change();
228
+ await runTimers(150);
229
+ assert.match(harness.statuses.get("plastic-branch") ?? "", /\/main\/inner/);
230
+ assert.ok(watched.some((entry) => entry.path === "/workspace/inner/.plastic" && !entry.closed));
231
+ assert.equal(watched.some((entry) => entry.path === "/workspace/inner" && !entry.closed), false);
232
+ await harness.emit("session_shutdown");
233
+ assert.ok(watched.every((entry) => entry.closed));
234
+ }
235
+
236
+ {
237
+ const source = await readFile(new URL("../extensions/plastic-branch-status.ts", import.meta.url), "utf8");
238
+ assert.doesNotMatch(source, /\.setFooter\s*\(/, "additive branch extension must never replace Pi's footer");
239
+ }
240
+
241
+ console.log("PASS: Plastic branch footer lifecycle and composition tests passed");