planning-with-files 3.9.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 +131 -0
- package/SKILL.md +262 -0
- package/examples.md +202 -0
- package/extensions/planning-with-files/README.md +35 -0
- package/extensions/planning-with-files/__tests__/attestation.test.ts +79 -0
- package/extensions/planning-with-files/__tests__/plan-anchor.test.ts +228 -0
- package/extensions/planning-with-files/__tests__/runtime.test.ts +688 -0
- package/extensions/planning-with-files/attestation.ts +55 -0
- package/extensions/planning-with-files/constants.ts +31 -0
- package/extensions/planning-with-files/index.ts +6 -0
- package/extensions/planning-with-files/package.json +17 -0
- package/extensions/planning-with-files/plan.ts +263 -0
- package/extensions/planning-with-files/runtime.ts +788 -0
- package/package.json +46 -0
- package/reference.md +218 -0
- package/scripts/attest-plan.ps1 +137 -0
- package/scripts/attest-plan.sh +206 -0
- package/scripts/check-complete.ps1 +253 -0
- package/scripts/check-complete.sh +253 -0
- package/scripts/init-session.ps1 +230 -0
- package/scripts/init-session.sh +370 -0
- package/scripts/plan-doctor.sh +148 -0
- package/scripts/resolve-plan-dir.ps1 +106 -0
- package/scripts/resolve-plan-dir.sh +263 -0
- package/scripts/session-catchup.py +876 -0
- package/scripts/set-active-plan.ps1 +51 -0
- package/scripts/set-active-plan.sh +50 -0
- package/templates/analytics_findings.md +85 -0
- package/templates/analytics_task_plan.md +106 -0
- package/templates/findings.md +95 -0
- package/templates/progress.md +114 -0
- package/templates/task_plan.md +140 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import type { PlanStatus } from "./plan.ts";
|
|
4
|
+
|
|
5
|
+
export interface AttestationCheck {
|
|
6
|
+
enabled: boolean;
|
|
7
|
+
tampered: boolean;
|
|
8
|
+
expected?: string;
|
|
9
|
+
actual?: string;
|
|
10
|
+
attestationPath?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function normalizeHash(value: string): string | undefined {
|
|
14
|
+
const hash = value.trim().toLowerCase();
|
|
15
|
+
if (!/^[a-f0-9]{64}$/.test(hash)) return undefined;
|
|
16
|
+
return hash;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function sha256File(path: string): string | undefined {
|
|
20
|
+
try {
|
|
21
|
+
const content = readFileSync(path);
|
|
22
|
+
return createHash("sha256").update(content).digest("hex");
|
|
23
|
+
} catch {
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function checkPlanAttestation(status: PlanStatus): AttestationCheck {
|
|
29
|
+
if (!status.exists || !status.planPath) {
|
|
30
|
+
return { enabled: false, tampered: false };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const attestationPath = status.attestationCandidates.find((candidate) => existsSync(candidate));
|
|
34
|
+
if (!attestationPath) {
|
|
35
|
+
return { enabled: false, tampered: false };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const expected = normalizeHash(readFileSync(attestationPath, "utf-8"));
|
|
39
|
+
if (!expected) {
|
|
40
|
+
return { enabled: true, tampered: true, attestationPath };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const actual = sha256File(status.planPath);
|
|
44
|
+
if (!actual) {
|
|
45
|
+
return { enabled: true, tampered: true, expected, attestationPath };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
enabled: true,
|
|
50
|
+
tampered: actual !== expected,
|
|
51
|
+
expected,
|
|
52
|
+
actual,
|
|
53
|
+
attestationPath,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export const PKG_NAME = "planning-with-files";
|
|
2
|
+
export const CUSTOM_TYPE = "planning-with-files";
|
|
3
|
+
|
|
4
|
+
export const PLAN_DATA_BEGIN = "===BEGIN PLAN DATA===";
|
|
5
|
+
export const PLAN_DATA_END = "===END PLAN DATA===";
|
|
6
|
+
|
|
7
|
+
// Keep this reminder stable in cache-safe mode.
|
|
8
|
+
export const CACHE_SAFE_REMINDER =
|
|
9
|
+
"[planning-with-files] Read task_plan.md for current phase and status. " +
|
|
10
|
+
"Read findings.md for research context. Read progress.md for recent changes. " +
|
|
11
|
+
"Continue from the current phase.";
|
|
12
|
+
|
|
13
|
+
// Keep this reminder stable in cache-safe mode.
|
|
14
|
+
export const PRE_TOOL_CACHE_SAFE_REMINDER =
|
|
15
|
+
"[planning-with-files] Before tool use, read task_plan.md for the active phase and constraints.";
|
|
16
|
+
|
|
17
|
+
export const POST_WRITE_REMINDER =
|
|
18
|
+
"[planning-with-files] Update progress.md with what you just did. If a phase is now complete, update task_plan.md status.";
|
|
19
|
+
|
|
20
|
+
export const TAMPERED_PREFIX = "[planning-with-files] [PLAN TAMPERED — injection blocked]";
|
|
21
|
+
|
|
22
|
+
export const AUTO_CONTINUE_LIMIT = 3;
|
|
23
|
+
|
|
24
|
+
export const DEFAULT_LOOP_INTERVAL_MS = 10 * 60 * 1000;
|
|
25
|
+
export const DEFAULT_LOOP_PROMPT =
|
|
26
|
+
"Read task_plan.md and progress.md. Run scripts/check-complete.sh to see remaining phases. " +
|
|
27
|
+
"If no progress.md entry has been added since the last loop tick, write one summarizing the current state. " +
|
|
28
|
+
"If a phase finished, update its Status: line in task_plan.md. Continue the next phase if work remains.";
|
|
29
|
+
|
|
30
|
+
export const DEFAULT_GOAL_CONDITION =
|
|
31
|
+
"all phases in task_plan.md report Status: complete and check-complete.sh reports ALL PHASES COMPLETE";
|
|
@@ -0,0 +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
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, join, sep } from "node:path";
|
|
3
|
+
|
|
4
|
+
export type PlanScope = "scoped" | "root" | "none";
|
|
5
|
+
|
|
6
|
+
export interface PlanPaths {
|
|
7
|
+
cwd: string;
|
|
8
|
+
scope: PlanScope;
|
|
9
|
+
planPath?: string;
|
|
10
|
+
progressPath?: string;
|
|
11
|
+
findingsPath?: string;
|
|
12
|
+
planDir?: string;
|
|
13
|
+
planId?: string;
|
|
14
|
+
attestationCandidates: string[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface PlanStatus extends PlanPaths {
|
|
18
|
+
exists: boolean;
|
|
19
|
+
closed: boolean;
|
|
20
|
+
totalPhases: number;
|
|
21
|
+
completePhases: number;
|
|
22
|
+
inProgressPhases: number;
|
|
23
|
+
pendingPhases: number;
|
|
24
|
+
firstLines50: string;
|
|
25
|
+
headLines30: string;
|
|
26
|
+
progressTail20: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function safeRead(path: string): string {
|
|
30
|
+
try {
|
|
31
|
+
return readFileSync(path, "utf-8");
|
|
32
|
+
} catch {
|
|
33
|
+
return "";
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Wall-clock times inside the injected progress tail move on every fire, so the
|
|
38
|
+
// bytes after them stop matching a cached prefix. The shell hooks have flattened
|
|
39
|
+
// them since v2.40; this is the same substitution, kept equivalent to the sed -E
|
|
40
|
+
// expression in scripts/inject-plan.sh so every route emits identical bytes for
|
|
41
|
+
// identical input. Matters most in parity mode, which re-sends the tail each turn.
|
|
42
|
+
export function normalizeWallClock(text: string): string {
|
|
43
|
+
return text
|
|
44
|
+
.replace(/T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]+)?Z/g, "T00:00:00Z")
|
|
45
|
+
.replace(/T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]+)?([+-][0-9]{2}:[0-9]{2})/g, "T00:00:00$2");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function resolveNewestPlanDir(planRoot: string): string | undefined {
|
|
49
|
+
if (!existsSync(planRoot)) return undefined;
|
|
50
|
+
|
|
51
|
+
const dirs = readdirSync(planRoot, { withFileTypes: true })
|
|
52
|
+
.filter((entry) => entry.isDirectory() && !entry.name.startsWith(".") && SLUG_RE.test(entry.name))
|
|
53
|
+
.map((entry) => join(planRoot, entry.name))
|
|
54
|
+
.filter((dir) => existsSync(join(dir, "task_plan.md")))
|
|
55
|
+
.map((dir) => {
|
|
56
|
+
let mtime = 0;
|
|
57
|
+
try {
|
|
58
|
+
// Rank by task_plan.md mtime, not the directory: editing a plan's contents does not bump the dir mtime, which let a completed plan lose to a stale sibling (#203).
|
|
59
|
+
mtime = statSync(join(dir, "task_plan.md")).mtimeMs;
|
|
60
|
+
} catch {
|
|
61
|
+
mtime = 0;
|
|
62
|
+
}
|
|
63
|
+
return { dir, mtime };
|
|
64
|
+
})
|
|
65
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
66
|
+
|
|
67
|
+
return dirs[0]?.dir;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Same shape as the sh resolver's slug_is_valid: first char [A-Za-z0-9_],
|
|
71
|
+
// rest [A-Za-z0-9._-]. Blocks traversal tokens (no separators), hidden names,
|
|
72
|
+
// and whitespace before any path is built; keeps Pi resolution in lockstep
|
|
73
|
+
// with resolve-plan-dir.sh on the same trees.
|
|
74
|
+
const SLUG_RE = /^[A-Za-z0-9_][A-Za-z0-9._-]*$/;
|
|
75
|
+
|
|
76
|
+
// Containment (security A1.3 parity with the sh resolver): a scoped candidate
|
|
77
|
+
// must canonicalize to a path under the anchor, or a symlinked/junctioned
|
|
78
|
+
// slug dir could hand the hooks an arbitrary file outside the project. Fails
|
|
79
|
+
// closed on canonicalization failure, matching resolve-plan-dir.sh.
|
|
80
|
+
function isWithinRoot(root: string, candidate: string): boolean {
|
|
81
|
+
let rootReal: string;
|
|
82
|
+
let candReal: string;
|
|
83
|
+
try {
|
|
84
|
+
rootReal = realpathSync(root);
|
|
85
|
+
candReal = realpathSync(candidate);
|
|
86
|
+
} catch {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
if (candReal === rootReal) return true;
|
|
90
|
+
return candReal.startsWith(rootReal.endsWith(sep) ? rootReal : rootReal + sep);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const ANCHOR_WALK_CAP = 10;
|
|
94
|
+
|
|
95
|
+
// The Pi session cwd follows the live shell, so an agent that cd's into a
|
|
96
|
+
// subdirectory used to lose the project's plan entirely: resolution found
|
|
97
|
+
// nothing, recitation went dark, and the "No task_plan.md found" warning
|
|
98
|
+
// fired on every write/edit (#208). Anchor resolution walks parents until a
|
|
99
|
+
// directory carries planning state (.planning/ or task_plan.md). A .git
|
|
100
|
+
// boundary without planning state, or the depth cap, stops the walk so a
|
|
101
|
+
// plan outside the repository can never leak into the session. Exported so
|
|
102
|
+
// runtime consumers that take a directory (attachment gate, mode config,
|
|
103
|
+
// script cwds) resolve from the same anchor as the plan itself.
|
|
104
|
+
export function resolveAnchor(cwd: string): string {
|
|
105
|
+
let dir = cwd;
|
|
106
|
+
for (let depth = 0; depth < ANCHOR_WALK_CAP; depth++) {
|
|
107
|
+
if (existsSync(join(dir, ".planning")) || existsSync(join(dir, "task_plan.md"))) {
|
|
108
|
+
return dir;
|
|
109
|
+
}
|
|
110
|
+
const parent = dirname(dir);
|
|
111
|
+
if (existsSync(join(dir, ".git")) || parent === dir) {
|
|
112
|
+
return cwd;
|
|
113
|
+
}
|
|
114
|
+
dir = parent;
|
|
115
|
+
}
|
|
116
|
+
return cwd;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function resolvePlanPaths(sessionCwd: string): PlanPaths {
|
|
120
|
+
// All plan paths are built on the anchor (the nearest ancestor with
|
|
121
|
+
// planning state), not the raw shell cwd. The returned cwd field carries
|
|
122
|
+
// the anchor; runtime call sites that take a directory route through
|
|
123
|
+
// resolveAnchor/anchorCwd so they land on the same plan.
|
|
124
|
+
const cwd = resolveAnchor(sessionCwd);
|
|
125
|
+
const planRoot = join(cwd, ".planning");
|
|
126
|
+
|
|
127
|
+
const makeScoped = (planDir: string): PlanPaths => ({
|
|
128
|
+
cwd,
|
|
129
|
+
scope: "scoped",
|
|
130
|
+
planDir,
|
|
131
|
+
planId: basename(planDir),
|
|
132
|
+
planPath: join(planDir, "task_plan.md"),
|
|
133
|
+
progressPath: join(planDir, "progress.md"),
|
|
134
|
+
findingsPath: join(planDir, "findings.md"),
|
|
135
|
+
attestationCandidates: [join(planDir, ".attestation"), join(cwd, ".plan-attestation")],
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const makeRoot = (): PlanPaths => ({
|
|
139
|
+
cwd,
|
|
140
|
+
scope: "root",
|
|
141
|
+
planPath: join(cwd, "task_plan.md"),
|
|
142
|
+
progressPath: join(cwd, "progress.md"),
|
|
143
|
+
findingsPath: join(cwd, "findings.md"),
|
|
144
|
+
attestationCandidates: [join(cwd, ".plan-attestation")],
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const planId = process.env.PLAN_ID?.trim();
|
|
148
|
+
if (planId && SLUG_RE.test(planId)) {
|
|
149
|
+
const candidate = join(planRoot, planId);
|
|
150
|
+
if (existsSync(join(candidate, "task_plan.md")) && isWithinRoot(cwd, candidate)) {
|
|
151
|
+
return makeScoped(candidate);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const activePlanFile = join(planRoot, ".active_plan");
|
|
156
|
+
if (existsSync(activePlanFile)) {
|
|
157
|
+
const activePlanId = safeRead(activePlanFile).trim();
|
|
158
|
+
if (activePlanId && SLUG_RE.test(activePlanId)) {
|
|
159
|
+
const candidate = join(planRoot, activePlanId);
|
|
160
|
+
if (existsSync(join(candidate, "task_plan.md")) && isWithinRoot(cwd, candidate)) {
|
|
161
|
+
return makeScoped(candidate);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const newest = resolveNewestPlanDir(planRoot);
|
|
167
|
+
// Containment is checked on the winner only; a rejected winner falls
|
|
168
|
+
// through to root/none (the safe direction) rather than promoting the
|
|
169
|
+
// next-newest sibling.
|
|
170
|
+
if (newest && isWithinRoot(cwd, newest)) {
|
|
171
|
+
return makeScoped(newest);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const rootPlan = makeRoot();
|
|
175
|
+
if (rootPlan.planPath && existsSync(rootPlan.planPath)) {
|
|
176
|
+
return rootPlan;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
cwd,
|
|
181
|
+
scope: "none",
|
|
182
|
+
attestationCandidates: [join(cwd, ".plan-attestation")],
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function readPlanStatus(cwd: string): PlanStatus {
|
|
187
|
+
const paths = resolvePlanPaths(cwd);
|
|
188
|
+
if (!paths.planPath || !existsSync(paths.planPath)) {
|
|
189
|
+
return {
|
|
190
|
+
...paths,
|
|
191
|
+
exists: false,
|
|
192
|
+
closed: false,
|
|
193
|
+
totalPhases: 0,
|
|
194
|
+
completePhases: 0,
|
|
195
|
+
inProgressPhases: 0,
|
|
196
|
+
pendingPhases: 0,
|
|
197
|
+
firstLines50: "",
|
|
198
|
+
headLines30: "",
|
|
199
|
+
progressTail20: "",
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const planContent = safeRead(paths.planPath);
|
|
204
|
+
const closed = /<!--\s*pwf:\s*closed\s*-->/i.test(planContent);
|
|
205
|
+
const lines = planContent.split("\n");
|
|
206
|
+
|
|
207
|
+
const phaseRegex = /^###\s+Phase\b/i;
|
|
208
|
+
const statusComplete = /\*\*Status:\*\*\s*complete\b/i;
|
|
209
|
+
const statusInProgress = /\*\*Status:\*\*\s*in_progress\b/i;
|
|
210
|
+
const statusPending = /\*\*Status:\*\*\s*pending\b/i;
|
|
211
|
+
|
|
212
|
+
let total = 0;
|
|
213
|
+
let complete = 0;
|
|
214
|
+
let inProgress = 0;
|
|
215
|
+
let pending = 0;
|
|
216
|
+
|
|
217
|
+
for (const line of lines) {
|
|
218
|
+
if (phaseRegex.test(line)) total += 1;
|
|
219
|
+
if (statusComplete.test(line)) complete += 1;
|
|
220
|
+
else if (statusInProgress.test(line)) inProgress += 1;
|
|
221
|
+
else if (statusPending.test(line)) pending += 1;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (complete + inProgress + pending === 0) {
|
|
225
|
+
complete = (planContent.match(/\[complete\]/gi) || []).length;
|
|
226
|
+
inProgress = (planContent.match(/\[in_progress\]/gi) || []).length;
|
|
227
|
+
pending = (planContent.match(/\[pending\]/gi) || []).length;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
let progressTail20 = "";
|
|
231
|
+
if (paths.progressPath && existsSync(paths.progressPath)) {
|
|
232
|
+
const progressLines = safeRead(paths.progressPath).split("\n");
|
|
233
|
+
progressTail20 = normalizeWallClock(progressLines.slice(-20).join("\n"));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
...paths,
|
|
238
|
+
exists: true,
|
|
239
|
+
closed,
|
|
240
|
+
totalPhases: total,
|
|
241
|
+
completePhases: complete,
|
|
242
|
+
inProgressPhases: inProgress,
|
|
243
|
+
pendingPhases: pending,
|
|
244
|
+
firstLines50: lines.slice(0, 50).join("\n"),
|
|
245
|
+
headLines30: lines.slice(0, 30).join("\n"),
|
|
246
|
+
progressTail20,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function isAllPhasesComplete(status: PlanStatus): boolean {
|
|
251
|
+
return status.exists && status.totalPhases > 0 && status.completePhases >= status.totalPhases;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function isPlanIncomplete(status: PlanStatus): boolean {
|
|
255
|
+
return status.exists && status.totalPhases > 0 && status.completePhases < status.totalPhases;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export function isSessionAttached(cwd: string, sessionId: string | undefined): boolean {
|
|
259
|
+
const sessionsDir = join(cwd, ".planning", "sessions");
|
|
260
|
+
if (!existsSync(sessionsDir)) return true;
|
|
261
|
+
if (!sessionId) return false;
|
|
262
|
+
return existsSync(join(sessionsDir, `${sessionId}.attached`));
|
|
263
|
+
}
|