planning-with-files 3.16.0 → 3.17.1
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 +65 -63
- package/SKILL.md +50 -49
- package/extensions/planning-with-files/__tests__/plan-anchor.test.ts +41 -1
- package/extensions/planning-with-files/__tests__/runtime.test.ts +46 -0
- package/extensions/planning-with-files/package.json +17 -17
- package/extensions/planning-with-files/plan.ts +50 -3
- package/extensions/planning-with-files/runtime.ts +45 -10
- package/package.json +1 -1
- package/scripts/attest-plan.ps1 +1 -0
- package/scripts/attest-plan.sh +4 -0
- package/scripts/check-complete.ps1 +1 -0
- package/scripts/check-complete.sh +3 -0
- package/scripts/inject-plan.py +1601 -0
- package/scripts/inject-plan.sh +69 -17
- package/scripts/resolve-plan-dir.ps1 +26 -1
- package/scripts/resolve-plan-dir.sh +32 -2
- package/scripts/skill-hook.sh +465 -0
- package/templates/loop.md +37 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
|
|
1
|
+
import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
|
|
2
2
|
import { basename, dirname, join, sep } from "node:path";
|
|
3
3
|
|
|
4
4
|
export type PlanScope = "scoped" | "root" | "none";
|
|
@@ -6,6 +6,7 @@ export type PlanScope = "scoped" | "root" | "none";
|
|
|
6
6
|
export interface PlanPaths {
|
|
7
7
|
cwd: string;
|
|
8
8
|
scope: PlanScope;
|
|
9
|
+
selectionError?: "session-plan-ambiguous";
|
|
9
10
|
planPath?: string;
|
|
10
11
|
progressPath?: string;
|
|
11
12
|
findingsPath?: string;
|
|
@@ -14,6 +15,9 @@ export interface PlanPaths {
|
|
|
14
15
|
attestationCandidates: string[];
|
|
15
16
|
}
|
|
16
17
|
|
|
18
|
+
export const SESSION_PLAN_AMBIGUOUS_NOTICE =
|
|
19
|
+
"[planning-with-files] Multiple plans are available while session isolation is armed. Set PLAN_ID=<slug> for this session; nothing injected.";
|
|
20
|
+
|
|
17
21
|
export interface PlanStatus extends PlanPaths {
|
|
18
22
|
exists: boolean;
|
|
19
23
|
closed: boolean;
|
|
@@ -73,6 +77,39 @@ function resolveNewestPlanDir(planRoot: string): string | undefined {
|
|
|
73
77
|
// with resolve-plan-dir.sh on the same trees.
|
|
74
78
|
const SLUG_RE = /^[A-Za-z0-9_][A-Za-z0-9._-]*$/;
|
|
75
79
|
|
|
80
|
+
type SessionIsolationState = "absent" | "armed" | "invalid";
|
|
81
|
+
|
|
82
|
+
function sessionIsolationState(cwd: string): SessionIsolationState {
|
|
83
|
+
try {
|
|
84
|
+
const sessions = lstatSync(join(cwd, ".planning", "sessions"));
|
|
85
|
+
return sessions.isDirectory() && !sessions.isSymbolicLink() ? "armed" : "invalid";
|
|
86
|
+
} catch (error) {
|
|
87
|
+
return (error as NodeJS.ErrnoException).code === "ENOENT" ? "absent" : "invalid";
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function isSessionIsolationArmed(cwd: string): boolean {
|
|
92
|
+
return sessionIsolationState(cwd) === "armed";
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function hasMultipleLivePlans(cwd: string, planRoot: string): boolean {
|
|
96
|
+
let count = existsSync(join(cwd, "task_plan.md")) ? 1 : 0;
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
for (const entry of readdirSync(planRoot, { withFileTypes: true })) {
|
|
100
|
+
if (!entry.isDirectory() || entry.name.startsWith(".") || !SLUG_RE.test(entry.name)) continue;
|
|
101
|
+
const candidate = join(planRoot, entry.name);
|
|
102
|
+
if (!existsSync(join(candidate, "task_plan.md")) || !isWithinRoot(cwd, candidate)) continue;
|
|
103
|
+
count += 1;
|
|
104
|
+
if (count > 1) return true;
|
|
105
|
+
}
|
|
106
|
+
} catch {
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
|
|
76
113
|
// Containment (security A1.3 parity with the sh resolver): a scoped candidate
|
|
77
114
|
// must canonicalize to a path under the anchor, or a symlinked/junctioned
|
|
78
115
|
// slug dir could hand the hooks an arbitrary file outside the project. Fails
|
|
@@ -144,9 +181,10 @@ export function resolvePlanPaths(sessionCwd: string): PlanPaths {
|
|
|
144
181
|
attestationCandidates: [join(cwd, ".plan-attestation")],
|
|
145
182
|
});
|
|
146
183
|
|
|
147
|
-
const makeNone = (): PlanPaths => ({
|
|
184
|
+
const makeNone = (selectionError?: PlanPaths["selectionError"]): PlanPaths => ({
|
|
148
185
|
cwd,
|
|
149
186
|
scope: "none",
|
|
187
|
+
selectionError,
|
|
150
188
|
attestationCandidates: [join(cwd, ".plan-attestation")],
|
|
151
189
|
});
|
|
152
190
|
|
|
@@ -175,6 +213,14 @@ export function resolvePlanPaths(sessionCwd: string): PlanPaths {
|
|
|
175
213
|
return makeNone();
|
|
176
214
|
}
|
|
177
215
|
|
|
216
|
+
// An attachment admits a session to the project, but it does not choose one
|
|
217
|
+
// of several plans. With isolation armed, the shared pointer and newest-plan
|
|
218
|
+
// fallback would otherwise cross session boundaries. Match the canonical hook:
|
|
219
|
+
// an explicit PLAN_ID remains the only task selector in this state.
|
|
220
|
+
if (isSessionIsolationArmed(cwd) && hasMultipleLivePlans(cwd, planRoot)) {
|
|
221
|
+
return makeNone("session-plan-ambiguous");
|
|
222
|
+
}
|
|
223
|
+
|
|
178
224
|
const activePlanFile = join(planRoot, ".active_plan");
|
|
179
225
|
if (existsSync(activePlanFile)) {
|
|
180
226
|
const activePlanId = safeRead(activePlanFile).trim();
|
|
@@ -276,7 +322,8 @@ export function isPlanIncomplete(status: PlanStatus): boolean {
|
|
|
276
322
|
|
|
277
323
|
export function isSessionAttached(cwd: string, sessionId: string | undefined): boolean {
|
|
278
324
|
const sessionsDir = join(cwd, ".planning", "sessions");
|
|
279
|
-
if (
|
|
325
|
+
if (sessionIsolationState(cwd) === "absent") return true;
|
|
326
|
+
if (!isSessionIsolationArmed(cwd)) return false;
|
|
280
327
|
if (!sessionId) return false;
|
|
281
328
|
return existsSync(join(sessionsDir, `${sessionId}.attached`));
|
|
282
329
|
}
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
isPlanIncomplete,
|
|
29
29
|
isSessionAttached,
|
|
30
30
|
readPlanStatus,
|
|
31
|
+
SESSION_PLAN_AMBIGUOUS_NOTICE,
|
|
31
32
|
type PlanStatus,
|
|
32
33
|
resolveAnchor,
|
|
33
34
|
} from "./plan.ts";
|
|
@@ -141,6 +142,10 @@ function isAttachedSession(ctx: ExtensionContext): boolean {
|
|
|
141
142
|
return isSessionAttached(anchorCwd(ctx), getSessionId(ctx));
|
|
142
143
|
}
|
|
143
144
|
|
|
145
|
+
function isAmbiguousSessionPlan(status: PlanStatus): boolean {
|
|
146
|
+
return status.selectionError === "session-plan-ambiguous";
|
|
147
|
+
}
|
|
148
|
+
|
|
144
149
|
function runCommand(cmd: string, args: string[], cwd: string): ExecResult {
|
|
145
150
|
const result = spawnSync(cmd, args, {
|
|
146
151
|
cwd,
|
|
@@ -176,13 +181,13 @@ function runSessionCatchup(cwd: string): ExecResult {
|
|
|
176
181
|
return { ok: false, stdout: "", stderr: `missing catchup script: ${CATCHUP_SCRIPT}` };
|
|
177
182
|
}
|
|
178
183
|
|
|
179
|
-
return runFirstSuccessful(
|
|
180
|
-
[
|
|
181
|
-
["uv", ["run", CATCHUP_SCRIPT, "--no-history", cwd]],
|
|
182
|
-
["python3", [CATCHUP_SCRIPT, "--no-history", cwd]],
|
|
183
|
-
["python", [CATCHUP_SCRIPT, "--no-history", cwd]],
|
|
184
|
-
["py", ["-3", CATCHUP_SCRIPT, "--no-history", cwd]],
|
|
185
|
-
],
|
|
184
|
+
return runFirstSuccessful(
|
|
185
|
+
[
|
|
186
|
+
["uv", ["run", CATCHUP_SCRIPT, "--no-history", cwd]],
|
|
187
|
+
["python3", [CATCHUP_SCRIPT, "--no-history", cwd]],
|
|
188
|
+
["python", [CATCHUP_SCRIPT, "--no-history", cwd]],
|
|
189
|
+
["py", ["-3", CATCHUP_SCRIPT, "--no-history", cwd]],
|
|
190
|
+
],
|
|
186
191
|
cwd,
|
|
187
192
|
);
|
|
188
193
|
}
|
|
@@ -353,6 +358,10 @@ function registerCommands(pi: ExtensionAPI, state: RuntimeState): void {
|
|
|
353
358
|
description: "Show current planning-with-files plan status",
|
|
354
359
|
handler: async (_args, ctx) => {
|
|
355
360
|
const status = readPlanStatus(ctx.cwd);
|
|
361
|
+
if (isAmbiguousSessionPlan(status)) {
|
|
362
|
+
ctx.ui.notify(SESSION_PLAN_AMBIGUOUS_NOTICE, "warning");
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
356
365
|
if (!status.exists) {
|
|
357
366
|
ctx.ui.notify("No active plan (task_plan.md not found)", "warning");
|
|
358
367
|
return;
|
|
@@ -373,6 +382,10 @@ function registerCommands(pi: ExtensionAPI, state: RuntimeState): void {
|
|
|
373
382
|
pi.registerCommand("plan-attest", {
|
|
374
383
|
description: "Run attest-plan helper for the active plan (--show / --clear supported)",
|
|
375
384
|
handler: async (args, ctx) => {
|
|
385
|
+
if (isAmbiguousSessionPlan(readPlanStatus(ctx.cwd))) {
|
|
386
|
+
ctx.ui.notify(SESSION_PLAN_AMBIGUOUS_NOTICE, "warning");
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
376
389
|
const flags = args.trim() ? args.trim().split(/\s+/) : [];
|
|
377
390
|
const result = runAttestScript(anchorCwd(ctx), flags);
|
|
378
391
|
if (result.ok) {
|
|
@@ -404,6 +417,10 @@ function registerCommands(pi: ExtensionAPI, state: RuntimeState): void {
|
|
|
404
417
|
description: "Approve the active plan and enable planning-with-files hook activation",
|
|
405
418
|
handler: async (args, ctx) => {
|
|
406
419
|
const status = readPlanStatus(ctx.cwd);
|
|
420
|
+
if (isAmbiguousSessionPlan(status)) {
|
|
421
|
+
ctx.ui.notify(SESSION_PLAN_AMBIGUOUS_NOTICE, "warning");
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
407
424
|
if (!status.exists) {
|
|
408
425
|
ctx.ui.notify("No active plan (task_plan.md not found)", "warning");
|
|
409
426
|
return;
|
|
@@ -455,6 +472,11 @@ function registerCommands(pi: ExtensionAPI, state: RuntimeState): void {
|
|
|
455
472
|
const intervalMs = maybeInterval ?? DEFAULT_LOOP_INTERVAL_MS;
|
|
456
473
|
const prompt = maybeInterval ? parts.slice(1).join(" ").trim() : parts.join(" ").trim();
|
|
457
474
|
const tickPrompt = prompt || DEFAULT_LOOP_PROMPT;
|
|
475
|
+
const initialStatus = readPlanStatus(ctx.cwd);
|
|
476
|
+
if (isAmbiguousSessionPlan(initialStatus)) {
|
|
477
|
+
ctx.ui.notify(SESSION_PLAN_AMBIGUOUS_NOTICE, "warning");
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
458
480
|
|
|
459
481
|
const existing = state.loopTimersBySession.get(sessionId);
|
|
460
482
|
if (existing) clearInterval(existing);
|
|
@@ -509,11 +531,15 @@ export default function planningWithFilesExtension(pi: ExtensionAPI): void {
|
|
|
509
531
|
return;
|
|
510
532
|
}
|
|
511
533
|
|
|
534
|
+
const status = readPlanStatus(ctx.cwd);
|
|
535
|
+
if (isAmbiguousSessionPlan(status)) {
|
|
536
|
+
ctx.ui.setStatus(PKG_NAME, "multiple plans require PLAN_ID");
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
|
|
512
540
|
if (["startup", "new", "resume", "fork"].includes(event.reason)) {
|
|
513
541
|
runSessionCatchup(anchorCwd(ctx));
|
|
514
542
|
}
|
|
515
|
-
|
|
516
|
-
const status = readPlanStatus(ctx.cwd);
|
|
517
543
|
if (status.exists) {
|
|
518
544
|
setPassivePlanStatus(ctx, status);
|
|
519
545
|
}
|
|
@@ -537,6 +563,15 @@ export default function planningWithFilesExtension(pi: ExtensionAPI): void {
|
|
|
537
563
|
if (!isAttachedSession(ctx)) return;
|
|
538
564
|
|
|
539
565
|
const status = readPlanStatus(ctx.cwd);
|
|
566
|
+
if (isAmbiguousSessionPlan(status)) {
|
|
567
|
+
return {
|
|
568
|
+
message: {
|
|
569
|
+
customType: CUSTOM_TYPE,
|
|
570
|
+
content: SESSION_PLAN_AMBIGUOUS_NOTICE,
|
|
571
|
+
display: true,
|
|
572
|
+
},
|
|
573
|
+
};
|
|
574
|
+
}
|
|
540
575
|
if (!status.exists) return;
|
|
541
576
|
|
|
542
577
|
if (!isExecutionApproved(state, ctx, status)) {
|
|
@@ -622,7 +657,7 @@ export default function planningWithFilesExtension(pi: ExtensionAPI): void {
|
|
|
622
657
|
}
|
|
623
658
|
}
|
|
624
659
|
|
|
625
|
-
if (!status.exists && (event.toolName === "write" || event.toolName === "edit")) {
|
|
660
|
+
if (!isAmbiguousSessionPlan(status) && !status.exists && (event.toolName === "write" || event.toolName === "edit")) {
|
|
626
661
|
ctx.ui.notify("[planning-with-files] No task_plan.md found. Create planning files first.", "warning");
|
|
627
662
|
}
|
|
628
663
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "planning-with-files",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.17.1",
|
|
4
4
|
"description": "Persistent project planning with selected context injection. Automatic recovery uses project files only; explicit catchup modes read same-project local session records for aggregate counts or bounded replay. The host-aware gate never runs Markdown-declared commands. No network upload path. Ships the skill plus a Pi Coding Agent extension.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
package/scripts/attest-plan.ps1
CHANGED
|
@@ -404,6 +404,7 @@ function Resolve-PlanFile {
|
|
|
404
404
|
$resolver = Join-Path $PSScriptRoot "resolve-plan-dir.ps1"
|
|
405
405
|
if (-not (Test-Path -LiteralPath $resolver -PathType Leaf)) { return $null }
|
|
406
406
|
$resolvedDir = @(& $resolver | Where-Object { $_ }) | Select-Object -First 1
|
|
407
|
+
if (-not $resolvedDir -and ((& $resolver -CheckAmbiguity) -eq "PWF_PLAN_AMBIGUOUS_V1")) { return $null }
|
|
407
408
|
if ($resolvedDir) {
|
|
408
409
|
$planFile = Join-Path $resolvedDir "task_plan.md"
|
|
409
410
|
return (Resolve-ContainedPlanFile -Candidate $planFile -ExpectedDirectory $resolvedDir)
|
package/scripts/attest-plan.sh
CHANGED
|
@@ -45,6 +45,10 @@ resolve_plan_file() {
|
|
|
45
45
|
plan_dir=""
|
|
46
46
|
if [ -f "${RESOLVER}" ]; then
|
|
47
47
|
plan_dir="$(sh "${RESOLVER}" 2>/dev/null)"
|
|
48
|
+
if [ -z "$plan_dir" ] && [ "$(sh "${RESOLVER}" --check-ambiguity 2>/dev/null)" = "PWF_PLAN_AMBIGUOUS_V1" ]; then
|
|
49
|
+
printf "[plan-attest] Multiple plans are available. Set PLAN_ID=<slug>; nothing was attested.\n" >&2
|
|
50
|
+
return 1
|
|
51
|
+
fi
|
|
48
52
|
fi
|
|
49
53
|
if [ -n "${plan_dir}" ] && [ -f "${plan_dir}/task_plan.md" ]; then
|
|
50
54
|
printf "%s\n" "${plan_dir}/task_plan.md"
|
|
@@ -35,6 +35,7 @@ if ($PlanFile -ne "") {
|
|
|
35
35
|
try {
|
|
36
36
|
$resolvedDir = (& $resolver 2>$null | Select-Object -First 1)
|
|
37
37
|
if ($null -eq $resolvedDir) { $resolvedDir = "" }
|
|
38
|
+
if (-not $resolvedDir -and ((& $resolver -CheckAmbiguity) -eq "PWF_PLAN_AMBIGUOUS_V1")) { exit 0 }
|
|
38
39
|
} catch {
|
|
39
40
|
$resolvedDir = ""
|
|
40
41
|
}
|
|
@@ -56,6 +56,9 @@ else
|
|
|
56
56
|
RESOLVED_DIR=""
|
|
57
57
|
if [ -f "${RESOLVER}" ]; then
|
|
58
58
|
RESOLVED_DIR="$(sh "${RESOLVER}" 2>/dev/null)"
|
|
59
|
+
if [ -z "$RESOLVED_DIR" ] && [ "$(sh "${RESOLVER}" --check-ambiguity 2>/dev/null)" = "PWF_PLAN_AMBIGUOUS_V1" ]; then
|
|
60
|
+
exit 0
|
|
61
|
+
fi
|
|
59
62
|
fi
|
|
60
63
|
if [ -n "${RESOLVED_DIR}" ] && [ -f "${RESOLVED_DIR}/task_plan.md" ]; then
|
|
61
64
|
PLAN_FILE="${RESOLVED_DIR}/task_plan.md"
|