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.
@@ -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 (!existsSync(sessionsDir)) return true;
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.16.0",
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",
@@ -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)
@@ -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"