planning-with-files 3.10.1 → 3.11.2
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/extensions/planning-with-files/__tests__/plan-anchor.test.ts +228 -228
- package/extensions/planning-with-files/package.json +17 -17
- package/extensions/planning-with-files/runtime.ts +788 -788
- package/package.json +1 -1
- package/scripts/attest-plan.ps1 +137 -137
- package/scripts/attest-plan.sh +206 -206
- package/scripts/check-complete.ps1 +253 -253
- package/scripts/check-complete.sh +253 -253
- package/scripts/gate-stop.sh +32 -32
- package/scripts/ledger-append.ps1 +180 -180
- package/scripts/ledger-append.sh +337 -337
- package/scripts/ledger-summary.ps1 +128 -128
- package/scripts/phase-status.ps1 +175 -175
- package/scripts/phase-status.sh +158 -158
- package/scripts/session-catchup.py +876 -876
- package/scripts/set-active-plan.ps1 +51 -51
- package/scripts/set-active-plan.sh +50 -50
|
@@ -1,228 +1,228 @@
|
|
|
1
|
-
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, utimesSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { tmpdir } from "node:os";
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
-
import { afterEach, describe, expect, it } from "vitest";
|
|
5
|
-
import { readPlanStatus, resolvePlanPaths } from "../plan.ts";
|
|
6
|
-
import { planLabel } from "../runtime.ts";
|
|
7
|
-
|
|
8
|
-
// Issue #208: the Pi session cwd follows the live shell. Before v3.8.1 an
|
|
9
|
-
// agent that cd'd into a subdirectory lost the project's plan entirely
|
|
10
|
-
// (scope=none, recitation dark, "No task_plan.md found" warning on every
|
|
11
|
-
// write/edit). Resolution now anchors on the nearest ancestor with planning
|
|
12
|
-
// state, bounded by a .git repository boundary and a depth cap.
|
|
13
|
-
|
|
14
|
-
const tempRoots: string[] = [];
|
|
15
|
-
|
|
16
|
-
function makeWorkspace(): string {
|
|
17
|
-
const cwd = mkdtempSync(join(tmpdir(), "pwf-pi-anchor-"));
|
|
18
|
-
tempRoots.push(cwd);
|
|
19
|
-
return cwd;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
function writeScopedPlan(root: string, id: string, content: string): void {
|
|
23
|
-
const planDir = join(root, ".planning", id);
|
|
24
|
-
mkdirSync(planDir, { recursive: true });
|
|
25
|
-
writeFileSync(join(planDir, "task_plan.md"), content);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function writeRootPlan(root: string, content: string): void {
|
|
29
|
-
writeFileSync(join(root, "task_plan.md"), content);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
afterEach(() => {
|
|
33
|
-
while (tempRoots.length > 0) {
|
|
34
|
-
const root = tempRoots.pop();
|
|
35
|
-
if (root) rmSync(root, { recursive: true, force: true });
|
|
36
|
-
}
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
describe("resolvePlanPaths anchor walk (#208)", () => {
|
|
40
|
-
it("resolves the ancestor scoped plan from a subdirectory", () => {
|
|
41
|
-
const root = makeWorkspace();
|
|
42
|
-
writeScopedPlan(root, "2026-07-21-demo", "# Task Plan: demo\n### Phase 1\n- **Status:** in_progress\n");
|
|
43
|
-
const sub = join(root, "src", "nested");
|
|
44
|
-
mkdirSync(sub, { recursive: true });
|
|
45
|
-
|
|
46
|
-
const paths = resolvePlanPaths(sub);
|
|
47
|
-
expect(paths.scope).toBe("scoped");
|
|
48
|
-
expect(paths.planId).toBe("2026-07-21-demo");
|
|
49
|
-
|
|
50
|
-
const status = readPlanStatus(sub);
|
|
51
|
-
expect(status.exists).toBe(true);
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
it("resolves the ancestor root plan from a subdirectory", () => {
|
|
55
|
-
const root = makeWorkspace();
|
|
56
|
-
writeRootPlan(root, "# Task Plan: rooty\n### Phase 1\n- **Status:** complete\n");
|
|
57
|
-
const sub = join(root, "lib");
|
|
58
|
-
mkdirSync(sub, { recursive: true });
|
|
59
|
-
|
|
60
|
-
const paths = resolvePlanPaths(sub);
|
|
61
|
-
expect(paths.scope).toBe("root");
|
|
62
|
-
expect(paths.planPath).toBe(join(root, "task_plan.md"));
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
it("does not walk past a .git repository boundary", () => {
|
|
66
|
-
const outer = makeWorkspace();
|
|
67
|
-
writeScopedPlan(outer, "2026-07-21-outer", "# Task Plan: outer\n");
|
|
68
|
-
const repo = join(outer, "inner-repo");
|
|
69
|
-
mkdirSync(join(repo, ".git"), { recursive: true });
|
|
70
|
-
const sub = join(repo, "src");
|
|
71
|
-
mkdirSync(sub, { recursive: true });
|
|
72
|
-
|
|
73
|
-
// From inside inner-repo (which has no plan), the outer plan must not
|
|
74
|
-
// leak in: the .git boundary stops the walk.
|
|
75
|
-
expect(resolvePlanPaths(sub).scope).toBe("none");
|
|
76
|
-
expect(resolvePlanPaths(repo).scope).toBe("none");
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
it("keeps slug-beats-root precedence at the anchor (documented since v2.40.0)", () => {
|
|
80
|
-
const root = makeWorkspace();
|
|
81
|
-
writeRootPlan(root, "# Task Plan: FRESH ROOT\n### Phase 1\n- **Status:** complete\n");
|
|
82
|
-
writeScopedPlan(root, "2026-01-01-stale-old", "# Task Plan: STALE OLD\n### Phase 1\n- **Status:** in_progress\n");
|
|
83
|
-
|
|
84
|
-
const paths = resolvePlanPaths(root);
|
|
85
|
-
expect(paths.scope).toBe("scoped");
|
|
86
|
-
expect(paths.planId).toBe("2026-01-01-stale-old");
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
it("still reports none when no ancestor carries planning state", () => {
|
|
90
|
-
const root = makeWorkspace();
|
|
91
|
-
const sub = join(root, "plain", "dir");
|
|
92
|
-
mkdirSync(sub, { recursive: true });
|
|
93
|
-
expect(resolvePlanPaths(sub).scope).toBe("none");
|
|
94
|
-
expect(readPlanStatus(sub).exists).toBe(false);
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
it("respects an explicit PLAN_ID pin from a subdirectory", () => {
|
|
98
|
-
const root = makeWorkspace();
|
|
99
|
-
writeScopedPlan(root, "2026-07-21-pinned", "# Task Plan: pinned\n");
|
|
100
|
-
writeScopedPlan(root, "2026-07-21-newer", "# Task Plan: newer\n");
|
|
101
|
-
const sub = join(root, "deep");
|
|
102
|
-
mkdirSync(sub, { recursive: true });
|
|
103
|
-
|
|
104
|
-
const previous = process.env.PLAN_ID;
|
|
105
|
-
process.env.PLAN_ID = "2026-07-21-pinned";
|
|
106
|
-
try {
|
|
107
|
-
const paths = resolvePlanPaths(sub);
|
|
108
|
-
expect(paths.scope).toBe("scoped");
|
|
109
|
-
expect(paths.planId).toBe("2026-07-21-pinned");
|
|
110
|
-
} finally {
|
|
111
|
-
if (previous === undefined) delete process.env.PLAN_ID;
|
|
112
|
-
else process.env.PLAN_ID = previous;
|
|
113
|
-
}
|
|
114
|
-
});
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
describe("slug validation and containment parity with the sh resolver (v3.8.1)", () => {
|
|
118
|
-
it("rejects a traversal PLAN_ID instead of escaping .planning", () => {
|
|
119
|
-
const root = makeWorkspace();
|
|
120
|
-
mkdirSync(join(root, "project", ".planning"), { recursive: true });
|
|
121
|
-
mkdirSync(join(root, "outside"), { recursive: true });
|
|
122
|
-
writeFileSync(join(root, "outside", "task_plan.md"), "# escaped");
|
|
123
|
-
|
|
124
|
-
const previous = process.env.PLAN_ID;
|
|
125
|
-
process.env.PLAN_ID = "../../outside";
|
|
126
|
-
try {
|
|
127
|
-
const paths = resolvePlanPaths(join(root, "project"));
|
|
128
|
-
expect(paths.scope).not.toBe("scoped");
|
|
129
|
-
expect(paths.planPath ?? "").not.toContain("outside");
|
|
130
|
-
} finally {
|
|
131
|
-
if (previous === undefined) delete process.env.PLAN_ID;
|
|
132
|
-
else process.env.PLAN_ID = previous;
|
|
133
|
-
}
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
it("rejects a PLAN_ID containing whitespace", () => {
|
|
137
|
-
const root = makeWorkspace();
|
|
138
|
-
writeScopedPlan(root, "plan a", "# spaced");
|
|
139
|
-
writeScopedPlan(root, "plan-fallback", "# fallback");
|
|
140
|
-
|
|
141
|
-
const previous = process.env.PLAN_ID;
|
|
142
|
-
process.env.PLAN_ID = "plan a";
|
|
143
|
-
try {
|
|
144
|
-
const paths = resolvePlanPaths(root);
|
|
145
|
-
expect(paths.planId).toBe("plan-fallback");
|
|
146
|
-
} finally {
|
|
147
|
-
if (previous === undefined) delete process.env.PLAN_ID;
|
|
148
|
-
else process.env.PLAN_ID = previous;
|
|
149
|
-
}
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
it("rejects a hidden .active_plan target and falls through", () => {
|
|
153
|
-
const root = makeWorkspace();
|
|
154
|
-
writeScopedPlan(root, ".hidden-plan", "# hidden");
|
|
155
|
-
writeScopedPlan(root, "plan-a", "# visible");
|
|
156
|
-
writeFileSync(join(root, ".planning", ".active_plan"), ".hidden-plan");
|
|
157
|
-
|
|
158
|
-
const paths = resolvePlanPaths(root);
|
|
159
|
-
expect(paths.planId).toBe("plan-a");
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
it("newest scan skips slug-invalid directory names", () => {
|
|
163
|
-
const root = makeWorkspace();
|
|
164
|
-
writeScopedPlan(root, "plan-valid", "# valid");
|
|
165
|
-
const bad = join(root, ".planning", "plan invalid name");
|
|
166
|
-
mkdirSync(bad, { recursive: true });
|
|
167
|
-
writeFileSync(join(bad, "task_plan.md"), "# bad");
|
|
168
|
-
const future = Date.now() / 1000 + 300;
|
|
169
|
-
utimesSync(join(bad, "task_plan.md"), future, future);
|
|
170
|
-
|
|
171
|
-
const paths = resolvePlanPaths(root);
|
|
172
|
-
expect(paths.planId).toBe("plan-valid");
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
it("rejects a junctioned slug dir pointing outside the project", () => {
|
|
176
|
-
const root = makeWorkspace();
|
|
177
|
-
const outside = join(root, "outside-target");
|
|
178
|
-
mkdirSync(outside, { recursive: true });
|
|
179
|
-
writeFileSync(join(outside, "task_plan.md"), "# outside");
|
|
180
|
-
const project = join(root, "project");
|
|
181
|
-
mkdirSync(join(project, ".planning"), { recursive: true });
|
|
182
|
-
try {
|
|
183
|
-
symlinkSync(outside, join(project, ".planning", "2026-07-21-evil"), "junction");
|
|
184
|
-
} catch {
|
|
185
|
-
return; // junction creation not permitted on this runner; nothing to assert
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
const paths = resolvePlanPaths(project);
|
|
189
|
-
expect(paths.scope).not.toBe("scoped");
|
|
190
|
-
});
|
|
191
|
-
|
|
192
|
-
it("sanitizes the injected plan label", () => {
|
|
193
|
-
const status = {
|
|
194
|
-
scope: "scoped",
|
|
195
|
-
planId: "evil`slug with spaces{and}stuff",
|
|
196
|
-
} as unknown as Parameters<typeof planLabel>[0];
|
|
197
|
-
const label = planLabel(status);
|
|
198
|
-
expect(label.startsWith("plan: ")).toBe(true);
|
|
199
|
-
expect(label.slice(6)).toMatch(/^[A-Za-z0-9._-]+$/);
|
|
200
|
-
});
|
|
201
|
-
|
|
202
|
-
// Issue #210: parity mode re-sends the progress tail every turn, so a moving
|
|
203
|
-
// wall-clock time costs cache reuse for everything after it. The shell hooks
|
|
204
|
-
// have flattened these since v2.40; this route had not.
|
|
205
|
-
it("flattens wall-clock times in the injected progress tail", () => {
|
|
206
|
-
const root = mkdtempSync(join(tmpdir(), "pwf-clock-"));
|
|
207
|
-
mkdirSync(join(root, ".planning", "demo"), { recursive: true });
|
|
208
|
-
writeFileSync(
|
|
209
|
-
join(root, ".planning", "demo", "task_plan.md"),
|
|
210
|
-
"# Plan\n\n### Phase 1: a\n- **Status:** in_progress\n",
|
|
211
|
-
);
|
|
212
|
-
writeFileSync(
|
|
213
|
-
join(root, ".planning", "demo", "progress.md"),
|
|
214
|
-
"# Progress\n- landed at 2026-08-01T11:02:55Z\n- again at 2026-08-01T09:16:03.221Z\n" +
|
|
215
|
-
"- offset 2026-08-01T14:30:00+02:00\n",
|
|
216
|
-
);
|
|
217
|
-
writeFileSync(join(root, ".planning", ".active_plan"), "demo\n");
|
|
218
|
-
|
|
219
|
-
const status = readPlanStatus(root);
|
|
220
|
-
expect(status.progressTail20).not.toContain("T11:02:55");
|
|
221
|
-
expect(status.progressTail20).not.toContain("T09:16:03");
|
|
222
|
-
expect(status.progressTail20).not.toContain("T14:30:00");
|
|
223
|
-
expect(status.progressTail20).toContain("T00:00:00Z");
|
|
224
|
-
// The UTC offset itself is content, only the clock is flattened.
|
|
225
|
-
expect(status.progressTail20).toContain("T00:00:00+02:00");
|
|
226
|
-
rmSync(root, { recursive: true, force: true });
|
|
227
|
-
});
|
|
228
|
-
});
|
|
1
|
+
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, utimesSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { readPlanStatus, resolvePlanPaths } from "../plan.ts";
|
|
6
|
+
import { planLabel } from "../runtime.ts";
|
|
7
|
+
|
|
8
|
+
// Issue #208: the Pi session cwd follows the live shell. Before v3.8.1 an
|
|
9
|
+
// agent that cd'd into a subdirectory lost the project's plan entirely
|
|
10
|
+
// (scope=none, recitation dark, "No task_plan.md found" warning on every
|
|
11
|
+
// write/edit). Resolution now anchors on the nearest ancestor with planning
|
|
12
|
+
// state, bounded by a .git repository boundary and a depth cap.
|
|
13
|
+
|
|
14
|
+
const tempRoots: string[] = [];
|
|
15
|
+
|
|
16
|
+
function makeWorkspace(): string {
|
|
17
|
+
const cwd = mkdtempSync(join(tmpdir(), "pwf-pi-anchor-"));
|
|
18
|
+
tempRoots.push(cwd);
|
|
19
|
+
return cwd;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function writeScopedPlan(root: string, id: string, content: string): void {
|
|
23
|
+
const planDir = join(root, ".planning", id);
|
|
24
|
+
mkdirSync(planDir, { recursive: true });
|
|
25
|
+
writeFileSync(join(planDir, "task_plan.md"), content);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function writeRootPlan(root: string, content: string): void {
|
|
29
|
+
writeFileSync(join(root, "task_plan.md"), content);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
while (tempRoots.length > 0) {
|
|
34
|
+
const root = tempRoots.pop();
|
|
35
|
+
if (root) rmSync(root, { recursive: true, force: true });
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("resolvePlanPaths anchor walk (#208)", () => {
|
|
40
|
+
it("resolves the ancestor scoped plan from a subdirectory", () => {
|
|
41
|
+
const root = makeWorkspace();
|
|
42
|
+
writeScopedPlan(root, "2026-07-21-demo", "# Task Plan: demo\n### Phase 1\n- **Status:** in_progress\n");
|
|
43
|
+
const sub = join(root, "src", "nested");
|
|
44
|
+
mkdirSync(sub, { recursive: true });
|
|
45
|
+
|
|
46
|
+
const paths = resolvePlanPaths(sub);
|
|
47
|
+
expect(paths.scope).toBe("scoped");
|
|
48
|
+
expect(paths.planId).toBe("2026-07-21-demo");
|
|
49
|
+
|
|
50
|
+
const status = readPlanStatus(sub);
|
|
51
|
+
expect(status.exists).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("resolves the ancestor root plan from a subdirectory", () => {
|
|
55
|
+
const root = makeWorkspace();
|
|
56
|
+
writeRootPlan(root, "# Task Plan: rooty\n### Phase 1\n- **Status:** complete\n");
|
|
57
|
+
const sub = join(root, "lib");
|
|
58
|
+
mkdirSync(sub, { recursive: true });
|
|
59
|
+
|
|
60
|
+
const paths = resolvePlanPaths(sub);
|
|
61
|
+
expect(paths.scope).toBe("root");
|
|
62
|
+
expect(paths.planPath).toBe(join(root, "task_plan.md"));
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("does not walk past a .git repository boundary", () => {
|
|
66
|
+
const outer = makeWorkspace();
|
|
67
|
+
writeScopedPlan(outer, "2026-07-21-outer", "# Task Plan: outer\n");
|
|
68
|
+
const repo = join(outer, "inner-repo");
|
|
69
|
+
mkdirSync(join(repo, ".git"), { recursive: true });
|
|
70
|
+
const sub = join(repo, "src");
|
|
71
|
+
mkdirSync(sub, { recursive: true });
|
|
72
|
+
|
|
73
|
+
// From inside inner-repo (which has no plan), the outer plan must not
|
|
74
|
+
// leak in: the .git boundary stops the walk.
|
|
75
|
+
expect(resolvePlanPaths(sub).scope).toBe("none");
|
|
76
|
+
expect(resolvePlanPaths(repo).scope).toBe("none");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("keeps slug-beats-root precedence at the anchor (documented since v2.40.0)", () => {
|
|
80
|
+
const root = makeWorkspace();
|
|
81
|
+
writeRootPlan(root, "# Task Plan: FRESH ROOT\n### Phase 1\n- **Status:** complete\n");
|
|
82
|
+
writeScopedPlan(root, "2026-01-01-stale-old", "# Task Plan: STALE OLD\n### Phase 1\n- **Status:** in_progress\n");
|
|
83
|
+
|
|
84
|
+
const paths = resolvePlanPaths(root);
|
|
85
|
+
expect(paths.scope).toBe("scoped");
|
|
86
|
+
expect(paths.planId).toBe("2026-01-01-stale-old");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("still reports none when no ancestor carries planning state", () => {
|
|
90
|
+
const root = makeWorkspace();
|
|
91
|
+
const sub = join(root, "plain", "dir");
|
|
92
|
+
mkdirSync(sub, { recursive: true });
|
|
93
|
+
expect(resolvePlanPaths(sub).scope).toBe("none");
|
|
94
|
+
expect(readPlanStatus(sub).exists).toBe(false);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("respects an explicit PLAN_ID pin from a subdirectory", () => {
|
|
98
|
+
const root = makeWorkspace();
|
|
99
|
+
writeScopedPlan(root, "2026-07-21-pinned", "# Task Plan: pinned\n");
|
|
100
|
+
writeScopedPlan(root, "2026-07-21-newer", "# Task Plan: newer\n");
|
|
101
|
+
const sub = join(root, "deep");
|
|
102
|
+
mkdirSync(sub, { recursive: true });
|
|
103
|
+
|
|
104
|
+
const previous = process.env.PLAN_ID;
|
|
105
|
+
process.env.PLAN_ID = "2026-07-21-pinned";
|
|
106
|
+
try {
|
|
107
|
+
const paths = resolvePlanPaths(sub);
|
|
108
|
+
expect(paths.scope).toBe("scoped");
|
|
109
|
+
expect(paths.planId).toBe("2026-07-21-pinned");
|
|
110
|
+
} finally {
|
|
111
|
+
if (previous === undefined) delete process.env.PLAN_ID;
|
|
112
|
+
else process.env.PLAN_ID = previous;
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe("slug validation and containment parity with the sh resolver (v3.8.1)", () => {
|
|
118
|
+
it("rejects a traversal PLAN_ID instead of escaping .planning", () => {
|
|
119
|
+
const root = makeWorkspace();
|
|
120
|
+
mkdirSync(join(root, "project", ".planning"), { recursive: true });
|
|
121
|
+
mkdirSync(join(root, "outside"), { recursive: true });
|
|
122
|
+
writeFileSync(join(root, "outside", "task_plan.md"), "# escaped");
|
|
123
|
+
|
|
124
|
+
const previous = process.env.PLAN_ID;
|
|
125
|
+
process.env.PLAN_ID = "../../outside";
|
|
126
|
+
try {
|
|
127
|
+
const paths = resolvePlanPaths(join(root, "project"));
|
|
128
|
+
expect(paths.scope).not.toBe("scoped");
|
|
129
|
+
expect(paths.planPath ?? "").not.toContain("outside");
|
|
130
|
+
} finally {
|
|
131
|
+
if (previous === undefined) delete process.env.PLAN_ID;
|
|
132
|
+
else process.env.PLAN_ID = previous;
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("rejects a PLAN_ID containing whitespace", () => {
|
|
137
|
+
const root = makeWorkspace();
|
|
138
|
+
writeScopedPlan(root, "plan a", "# spaced");
|
|
139
|
+
writeScopedPlan(root, "plan-fallback", "# fallback");
|
|
140
|
+
|
|
141
|
+
const previous = process.env.PLAN_ID;
|
|
142
|
+
process.env.PLAN_ID = "plan a";
|
|
143
|
+
try {
|
|
144
|
+
const paths = resolvePlanPaths(root);
|
|
145
|
+
expect(paths.planId).toBe("plan-fallback");
|
|
146
|
+
} finally {
|
|
147
|
+
if (previous === undefined) delete process.env.PLAN_ID;
|
|
148
|
+
else process.env.PLAN_ID = previous;
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("rejects a hidden .active_plan target and falls through", () => {
|
|
153
|
+
const root = makeWorkspace();
|
|
154
|
+
writeScopedPlan(root, ".hidden-plan", "# hidden");
|
|
155
|
+
writeScopedPlan(root, "plan-a", "# visible");
|
|
156
|
+
writeFileSync(join(root, ".planning", ".active_plan"), ".hidden-plan");
|
|
157
|
+
|
|
158
|
+
const paths = resolvePlanPaths(root);
|
|
159
|
+
expect(paths.planId).toBe("plan-a");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("newest scan skips slug-invalid directory names", () => {
|
|
163
|
+
const root = makeWorkspace();
|
|
164
|
+
writeScopedPlan(root, "plan-valid", "# valid");
|
|
165
|
+
const bad = join(root, ".planning", "plan invalid name");
|
|
166
|
+
mkdirSync(bad, { recursive: true });
|
|
167
|
+
writeFileSync(join(bad, "task_plan.md"), "# bad");
|
|
168
|
+
const future = Date.now() / 1000 + 300;
|
|
169
|
+
utimesSync(join(bad, "task_plan.md"), future, future);
|
|
170
|
+
|
|
171
|
+
const paths = resolvePlanPaths(root);
|
|
172
|
+
expect(paths.planId).toBe("plan-valid");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("rejects a junctioned slug dir pointing outside the project", () => {
|
|
176
|
+
const root = makeWorkspace();
|
|
177
|
+
const outside = join(root, "outside-target");
|
|
178
|
+
mkdirSync(outside, { recursive: true });
|
|
179
|
+
writeFileSync(join(outside, "task_plan.md"), "# outside");
|
|
180
|
+
const project = join(root, "project");
|
|
181
|
+
mkdirSync(join(project, ".planning"), { recursive: true });
|
|
182
|
+
try {
|
|
183
|
+
symlinkSync(outside, join(project, ".planning", "2026-07-21-evil"), "junction");
|
|
184
|
+
} catch {
|
|
185
|
+
return; // junction creation not permitted on this runner; nothing to assert
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const paths = resolvePlanPaths(project);
|
|
189
|
+
expect(paths.scope).not.toBe("scoped");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("sanitizes the injected plan label", () => {
|
|
193
|
+
const status = {
|
|
194
|
+
scope: "scoped",
|
|
195
|
+
planId: "evil`slug with spaces{and}stuff",
|
|
196
|
+
} as unknown as Parameters<typeof planLabel>[0];
|
|
197
|
+
const label = planLabel(status);
|
|
198
|
+
expect(label.startsWith("plan: ")).toBe(true);
|
|
199
|
+
expect(label.slice(6)).toMatch(/^[A-Za-z0-9._-]+$/);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// Issue #210: parity mode re-sends the progress tail every turn, so a moving
|
|
203
|
+
// wall-clock time costs cache reuse for everything after it. The shell hooks
|
|
204
|
+
// have flattened these since v2.40; this route had not.
|
|
205
|
+
it("flattens wall-clock times in the injected progress tail", () => {
|
|
206
|
+
const root = mkdtempSync(join(tmpdir(), "pwf-clock-"));
|
|
207
|
+
mkdirSync(join(root, ".planning", "demo"), { recursive: true });
|
|
208
|
+
writeFileSync(
|
|
209
|
+
join(root, ".planning", "demo", "task_plan.md"),
|
|
210
|
+
"# Plan\n\n### Phase 1: a\n- **Status:** in_progress\n",
|
|
211
|
+
);
|
|
212
|
+
writeFileSync(
|
|
213
|
+
join(root, ".planning", "demo", "progress.md"),
|
|
214
|
+
"# Progress\n- landed at 2026-08-01T11:02:55Z\n- again at 2026-08-01T09:16:03.221Z\n" +
|
|
215
|
+
"- offset 2026-08-01T14:30:00+02:00\n",
|
|
216
|
+
);
|
|
217
|
+
writeFileSync(join(root, ".planning", ".active_plan"), "demo\n");
|
|
218
|
+
|
|
219
|
+
const status = readPlanStatus(root);
|
|
220
|
+
expect(status.progressTail20).not.toContain("T11:02:55");
|
|
221
|
+
expect(status.progressTail20).not.toContain("T09:16:03");
|
|
222
|
+
expect(status.progressTail20).not.toContain("T14:30:00");
|
|
223
|
+
expect(status.progressTail20).toContain("T00:00:00Z");
|
|
224
|
+
// The UTC offset itself is content, only the clock is flattened.
|
|
225
|
+
expect(status.progressTail20).toContain("T00:00:00+02:00");
|
|
226
|
+
rmSync(root, { recursive: true, force: true });
|
|
227
|
+
});
|
|
228
|
+
});
|
|
@@ -1,17 +1,17 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "planning-with-files-pi-extension",
|
|
3
|
-
"version": "1.2.3",
|
|
4
|
-
"private": true,
|
|
5
|
-
"type": "module",
|
|
6
|
-
"scripts": {
|
|
7
|
-
"test": "vitest run"
|
|
8
|
-
},
|
|
9
|
-
"devDependencies": {
|
|
10
|
-
"@types/node": "^22.10.1",
|
|
11
|
-
"typescript": "^5.7.2",
|
|
12
|
-
"vitest": "^2.1.8"
|
|
13
|
-
},
|
|
14
|
-
"peerDependencies": {
|
|
15
|
-
"@earendil-works/pi-coding-agent": "*"
|
|
16
|
-
}
|
|
17
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "planning-with-files-pi-extension",
|
|
3
|
+
"version": "1.2.3",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test": "vitest run"
|
|
8
|
+
},
|
|
9
|
+
"devDependencies": {
|
|
10
|
+
"@types/node": "^22.10.1",
|
|
11
|
+
"typescript": "^5.7.2",
|
|
12
|
+
"vitest": "^2.1.8"
|
|
13
|
+
},
|
|
14
|
+
"peerDependencies": {
|
|
15
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
16
|
+
}
|
|
17
|
+
}
|