fix: refuse ambiguous unpinned named plans (#240)

This commit is contained in:
OthmanAdi
2026-09-08 12:08:09 +02:00
parent 0d21b6c4aa
commit ecaf63f689
127 changed files with 2349 additions and 803 deletions
@@ -404,6 +404,7 @@ function Resolve-PlanFile {
$resolver = Join-Path $PSScriptRoot "resolve-plan-dir.ps1"
if (-not (Test-Path -LiteralPath $resolver -PathType Leaf)) { return $null }
$resolvedDir = @(& $resolver | Where-Object { $_ }) | Select-Object -First 1
if (-not $resolvedDir -and ((& $resolver -CheckAmbiguity) -eq "PWF_PLAN_AMBIGUOUS_V1")) { return $null }
if ($resolvedDir) {
$planFile = Join-Path $resolvedDir "task_plan.md"
return (Resolve-ContainedPlanFile -Candidate $planFile -ExpectedDirectory $resolvedDir)
@@ -45,6 +45,10 @@ resolve_plan_file() {
plan_dir=""
if [ -f "${RESOLVER}" ]; then
plan_dir="$(sh "${RESOLVER}" 2>/dev/null)"
if [ -z "$plan_dir" ] && [ "$(sh "${RESOLVER}" --check-ambiguity 2>/dev/null)" = "PWF_PLAN_AMBIGUOUS_V1" ]; then
printf "[plan-attest] Multiple plans are available. Set PLAN_ID=<slug>; nothing was attested.\n" >&2
return 1
fi
fi
if [ -n "${plan_dir}" ] && [ -f "${plan_dir}/task_plan.md" ]; then
printf "%s\n" "${plan_dir}/task_plan.md"
@@ -35,6 +35,7 @@ if ($PlanFile -ne "") {
try {
$resolvedDir = (& $resolver 2>$null | Select-Object -First 1)
if ($null -eq $resolvedDir) { $resolvedDir = "" }
if (-not $resolvedDir -and ((& $resolver -CheckAmbiguity) -eq "PWF_PLAN_AMBIGUOUS_V1")) { exit 0 }
} catch {
$resolvedDir = ""
}
@@ -56,6 +56,9 @@ else
RESOLVED_DIR=""
if [ -f "${RESOLVER}" ]; then
RESOLVED_DIR="$(sh "${RESOLVER}" 2>/dev/null)"
if [ -z "$RESOLVED_DIR" ] && [ "$(sh "${RESOLVER}" --check-ambiguity 2>/dev/null)" = "PWF_PLAN_AMBIGUOUS_V1" ]; then
exit 0
fi
fi
if [ -n "${RESOLVED_DIR}" ] && [ -f "${RESOLVED_DIR}/task_plan.md" ]; then
PLAN_FILE="${RESOLVED_DIR}/task_plan.md"
@@ -59,12 +59,9 @@ Windows runs the shell chain through Git Bash and nothing else:
one turn-marker slot and one progress-guard slot per plan.
Known, accepted differences from the shell reference:
* Two plan directories with the same whole-second mtime, no .active_plan
and no PLAN_ID: the reference picks the first in the shell's glob order
(locale collation), this twin the first in code-point order. The same
collation difference can change which three of four or more nested
projects the ambiguity notice names. A machine runs one implementation,
so neither choice flips within a session.
* Shell glob order follows locale collation; this twin uses code-point
order. This can change which three of four or more nested projects the
ambiguity notice names.
* BSD sed (macOS) appends a newline to a progress.md whose last line has
none; GNU sed and this twin do not.
* With PWF_PLAN_ROOT set under Git Bash the shell sees the pin as typed
@@ -837,6 +834,16 @@ class Injector(object):
scope = ""
explicit = bool(plan_prefix)
plan_id = env.get("PLAN_ID", "")
ambiguous = plan_is_ambiguous(
plan_prefix + ".planning", plan_root_pin if plan_root_pin else ".", plan_id
)
if ambiguous and (context == "preflight" or not is_dir(plan_prefix + ".planning/sessions")):
if context == "userprompt":
self.echo(
"[planning-with-files] Multiple plans are available. Set PLAN_ID=<slug> "
"for this session; nothing injected."
)
raise Bail()
if plan_id:
if slug_is_valid(plan_id) and is_dir(plan_prefix + ".planning/" + plan_id):
resolved = plan_prefix + ".planning/" + plan_id
@@ -923,30 +930,14 @@ class Injector(object):
"single-session mode."
)
raise Bail()
if not plan_id:
plan_n = 1 if is_file(plan_prefix + "task_plan.md") else 0
try:
names = sorted(os.listdir(plan_prefix + ".planning"))
except OSError:
names = []
for name in names:
if name.startswith("."):
continue
if not is_file(plan_prefix + ".planning/" + name + "/task_plan.md"):
continue
if not slug_is_valid(name):
continue
plan_n += 1
if plan_n > 1:
break
if plan_n > 1:
if context == "userprompt":
self.echo(
"[planning-with-files] Multiple plans are available while session "
"isolation is armed. Set PLAN_ID=<slug> for this session; nothing "
"injected."
)
raise Bail()
if ambiguous:
if context == "userprompt":
self.echo(
"[planning-with-files] Multiple plans are available while session "
"isolation is armed. Set PLAN_ID=<slug> for this session; nothing "
"injected."
)
raise Bail()
if not explicit:
nested = []
@@ -1326,6 +1317,23 @@ def inject(context, env=None):
# Twin of resolve-plan-dir.sh (the shared resolver the dispatcher uses).
# --------------------------------------------------------------------------
def plan_is_ambiguous(plan_root, project_root, plan_id=""):
"""A project pointer or mtime cannot bind a session to multiple plans."""
if plan_id:
return False
count = 1 if is_dir(plan_root + "/sessions") and is_file(project_root + "/task_plan.md") else 0
try:
names = os.listdir(plan_root)
except OSError:
names = []
for name in names:
if slug_is_valid(name) and is_file(plan_root + "/" + name + "/task_plan.md"):
count += 1
if count > 1:
return True
return False
def resolve_plan_dir(env=None):
"""Return (spelled, filesystem) paths of the plan directory, or ("", "").
@@ -1360,6 +1368,9 @@ def resolve_plan_dir(env=None):
return found(plan_id)
return ("", "")
if plan_is_ambiguous(fs_root, pin if pin else "."):
return ("", "")
active_file = fs_root + "/.active_plan"
if is_file(active_file):
try:
@@ -1441,6 +1452,8 @@ class ClaudeDispatcher(object):
return (spelled, fs)
if self.env.get("PLAN_ID", "") or self.env.get("PWF_PLAN_ROOT", ""):
return ("", "")
if plan_is_ambiguous(".planning", "."):
return ("", "")
if is_file("task_plan.md"):
return (".", ".")
return ("", "")
@@ -254,6 +254,29 @@ is_within_root() {
# The .active_plan pointer, the newest-by-mtime fallback, and the legacy root
# task_plan.md are cwd GUESSES — only guesses are subject to the nested-root
# conflict check below.
# Shared .active_plan and directory mtime cannot identify this session's plan.
# Check before selection or preflight, even when isolation was never armed.
PLAN_AMBIGUOUS=0
if [ -z "${PLAN_ID:-}" ]; then
PLAN_COUNT=0
if [ -d "${PLAN_PREFIX}.planning/sessions" ] && [ -f "${PLAN_PREFIX}task_plan.md" ]; then
PLAN_COUNT=1
fi
for plan_candidate in "${PLAN_PREFIX}".planning/*/task_plan.md; do
[ -f "$plan_candidate" ] || continue
plan_candidate_dir="${plan_candidate%/task_plan.md}"
slug_is_valid "${plan_candidate_dir##*/}" || continue
PLAN_COUNT=$((PLAN_COUNT + 1))
if [ "$PLAN_COUNT" -gt 1 ]; then PLAN_AMBIGUOUS=1; break; fi
done
fi
if [ "$PLAN_AMBIGUOUS" = "1" ] && { [ "$CONTEXT" = "preflight" ] || [ ! -d "${PLAN_PREFIX}.planning/sessions" ]; }; then
if [ "$CONTEXT" = "userprompt" ]; then
echo "[planning-with-files] Multiple plans are available. Set PLAN_ID=<slug> for this session; nothing injected."
fi
exit 0
fi
RESOLVED=""
SCOPE=""
EXPLICIT=0
@@ -442,27 +465,13 @@ PY
exit 0
fi
# An attachment admits a session but does not select one of several plans.
# When isolation is armed, require PLAN_ID if more than one live same-root
# candidate exists. PWF_PLAN_ROOT selects the project root, not a plan
# within that root.
if [ -z "${PLAN_ID:-}" ]; then
SESSION_PLAN_N=0
[ -f "${PLAN_PREFIX}task_plan.md" ] && SESSION_PLAN_N=1
for candidate in "${PLAN_PREFIX}".planning/*/task_plan.md; do
[ -f "$candidate" ] || continue
candidate_dir="${candidate%/task_plan.md}"
candidate_slug="${candidate_dir##*/}"
slug_is_valid "$candidate_slug" || continue
SESSION_PLAN_N=$((SESSION_PLAN_N + 1))
[ "$SESSION_PLAN_N" -gt 1 ] && break
done
if [ "$SESSION_PLAN_N" -gt 1 ]; then
if [ "$CONTEXT" = "userprompt" ]; then
echo "[planning-with-files] Multiple plans are available while session isolation is armed. Set PLAN_ID=<slug> for this session; nothing injected."
fi
exit 0
# Attachment admits a session but does not select its plan. Preserve the
# attachment-first notice above for sessions that never opted in.
if [ "$PLAN_AMBIGUOUS" = "1" ]; then
if [ "$CONTEXT" = "userprompt" ]; then
echo "[planning-with-files] Multiple plans are available while session isolation is armed. Set PLAN_ID=<slug> for this session; nothing injected."
fi
exit 0
fi
fi
@@ -13,7 +13,8 @@
# junction/symlink escape; slug validation alone blocks textual traversal.
param(
[string]$PlanRoot = (Join-Path (Get-Location) ".planning")
[string]$PlanRoot = (Join-Path (Get-Location) ".planning"),
[switch]$CheckAmbiguity
)
$projectRoot = (Get-Location).Path
@@ -137,6 +138,30 @@ $activeFile = Join-Path $PlanRoot ".active_plan"
# at rc=0. Emptiness is the fail-closed signal on this channel, matching
# resolve-plan-dir.sh and the PWF_PLAN_ROOT pin. An empty $env:PLAN_ID is
# falsy here and still means "unset".
# The optional probe distinguishes ambiguity from a legacy-root fallback.
# Multiple named plans require PLAN_ID even without a sessions directory.
$planCount = 0
if (-not $env:PLAN_ID) {
if ((Test-Path -LiteralPath (Join-Path $PlanRoot "sessions") -PathType Container) -and
(Test-Path -LiteralPath (Join-Path $projectRoot "task_plan.md") -PathType Leaf)) {
$planCount = 1
}
if (Test-Path -LiteralPath $PlanRoot -PathType Container) {
foreach ($entry in (Get-ChildItem -LiteralPath $PlanRoot -Directory -ErrorAction SilentlyContinue)) {
if ((Test-ValidSlug $entry.Name) -and
(Test-Path -LiteralPath (Join-Path $entry.FullName "task_plan.md") -PathType Leaf)) {
$planCount++
if ($planCount -gt 1) { break }
}
}
}
}
if ($CheckAmbiguity) {
if ($planCount -gt 1) { Write-Output "PWF_PLAN_AMBIGUOUS_V1" }
exit 0
}
if ($planCount -gt 1) { exit 0 }
if ($env:PLAN_ID) {
if (Test-ValidSlug $env:PLAN_ID) {
$candidate = Join-Path $PlanRoot $env:PLAN_ID
@@ -15,6 +15,13 @@
set -u
# Optional probe distinguishes ambiguity from the empty legacy-root result.
# Both modes keep stdout data-only and always exit zero.
CHECK_AMBIGUITY=0
if [ "${1:-}" = "--check-ambiguity" ]; then
CHECK_AMBIGUITY=1
shift
fi
PLAN_ROOT="${1:-${PWD}/.planning}"
# --- PWF_PLAN_ROOT: absolute plan-root binding (issue #212). ---
@@ -321,6 +328,29 @@ resolve_latest_dir() {
# the fail-closed signal on this channel, exactly as the PWF_PLAN_ROOT guard
# above already does it; a non-zero status would kill callers running under
# set -e for a condition that is not an internal error.
# A shared pointer or mtime is not a per-session binding (issue #240).
# Count conservatively, just like injection: slug-valid live plan files.
# The legacy root joins the count only when session isolation is armed.
PLAN_AMBIGUOUS=0
if [ -z "${PLAN_ID:-}" ]; then
PLAN_COUNT=0
if [ -d "${PLAN_ROOT}/sessions" ] && [ -f "${PWF_ROOT_PIN:-.}/task_plan.md" ]; then
PLAN_COUNT=1
fi
for plan_candidate in "${PLAN_ROOT}"/*/task_plan.md; do
[ -f "$plan_candidate" ] || continue
plan_candidate_dir="${plan_candidate%/task_plan.md}"
slug_is_valid "${plan_candidate_dir##*/}" || continue
PLAN_COUNT=$((PLAN_COUNT + 1))
if [ "$PLAN_COUNT" -gt 1 ]; then PLAN_AMBIGUOUS=1; break; fi
done
fi
if [ "$CHECK_AMBIGUITY" = "1" ]; then
[ "$PLAN_AMBIGUOUS" = "1" ] && printf '%s\n' 'PWF_PLAN_AMBIGUOUS_V1'
exit 0
fi
[ "$PLAN_AMBIGUOUS" = "1" ] && exit 0
if [ -n "${PLAN_ID:-}" ]; then
resolve_from_env && exit 0
exit 0
@@ -404,6 +404,7 @@ function Resolve-PlanFile {
$resolver = Join-Path $PSScriptRoot "resolve-plan-dir.ps1"
if (-not (Test-Path -LiteralPath $resolver -PathType Leaf)) { return $null }
$resolvedDir = @(& $resolver | Where-Object { $_ }) | Select-Object -First 1
if (-not $resolvedDir -and ((& $resolver -CheckAmbiguity) -eq "PWF_PLAN_AMBIGUOUS_V1")) { return $null }
if ($resolvedDir) {
$planFile = Join-Path $resolvedDir "task_plan.md"
return (Resolve-ContainedPlanFile -Candidate $planFile -ExpectedDirectory $resolvedDir)
@@ -45,6 +45,10 @@ resolve_plan_file() {
plan_dir=""
if [ -f "${RESOLVER}" ]; then
plan_dir="$(sh "${RESOLVER}" 2>/dev/null)"
if [ -z "$plan_dir" ] && [ "$(sh "${RESOLVER}" --check-ambiguity 2>/dev/null)" = "PWF_PLAN_AMBIGUOUS_V1" ]; then
printf "[plan-attest] Multiple plans are available. Set PLAN_ID=<slug>; nothing was attested.\n" >&2
return 1
fi
fi
if [ -n "${plan_dir}" ] && [ -f "${plan_dir}/task_plan.md" ]; then
printf "%s\n" "${plan_dir}/task_plan.md"
@@ -35,6 +35,7 @@ if ($PlanFile -ne "") {
try {
$resolvedDir = (& $resolver 2>$null | Select-Object -First 1)
if ($null -eq $resolvedDir) { $resolvedDir = "" }
if (-not $resolvedDir -and ((& $resolver -CheckAmbiguity) -eq "PWF_PLAN_AMBIGUOUS_V1")) { exit 0 }
} catch {
$resolvedDir = ""
}
@@ -56,6 +56,9 @@ else
RESOLVED_DIR=""
if [ -f "${RESOLVER}" ]; then
RESOLVED_DIR="$(sh "${RESOLVER}" 2>/dev/null)"
if [ -z "$RESOLVED_DIR" ] && [ "$(sh "${RESOLVER}" --check-ambiguity 2>/dev/null)" = "PWF_PLAN_AMBIGUOUS_V1" ]; then
exit 0
fi
fi
if [ -n "${RESOLVED_DIR}" ] && [ -f "${RESOLVED_DIR}/task_plan.md" ]; then
PLAN_FILE="${RESOLVED_DIR}/task_plan.md"
@@ -59,12 +59,9 @@ Windows runs the shell chain through Git Bash and nothing else:
one turn-marker slot and one progress-guard slot per plan.
Known, accepted differences from the shell reference:
* Two plan directories with the same whole-second mtime, no .active_plan
and no PLAN_ID: the reference picks the first in the shell's glob order
(locale collation), this twin the first in code-point order. The same
collation difference can change which three of four or more nested
projects the ambiguity notice names. A machine runs one implementation,
so neither choice flips within a session.
* Shell glob order follows locale collation; this twin uses code-point
order. This can change which three of four or more nested projects the
ambiguity notice names.
* BSD sed (macOS) appends a newline to a progress.md whose last line has
none; GNU sed and this twin do not.
* With PWF_PLAN_ROOT set under Git Bash the shell sees the pin as typed
@@ -837,6 +834,16 @@ class Injector(object):
scope = ""
explicit = bool(plan_prefix)
plan_id = env.get("PLAN_ID", "")
ambiguous = plan_is_ambiguous(
plan_prefix + ".planning", plan_root_pin if plan_root_pin else ".", plan_id
)
if ambiguous and (context == "preflight" or not is_dir(plan_prefix + ".planning/sessions")):
if context == "userprompt":
self.echo(
"[planning-with-files] Multiple plans are available. Set PLAN_ID=<slug> "
"for this session; nothing injected."
)
raise Bail()
if plan_id:
if slug_is_valid(plan_id) and is_dir(plan_prefix + ".planning/" + plan_id):
resolved = plan_prefix + ".planning/" + plan_id
@@ -923,30 +930,14 @@ class Injector(object):
"single-session mode."
)
raise Bail()
if not plan_id:
plan_n = 1 if is_file(plan_prefix + "task_plan.md") else 0
try:
names = sorted(os.listdir(plan_prefix + ".planning"))
except OSError:
names = []
for name in names:
if name.startswith("."):
continue
if not is_file(plan_prefix + ".planning/" + name + "/task_plan.md"):
continue
if not slug_is_valid(name):
continue
plan_n += 1
if plan_n > 1:
break
if plan_n > 1:
if context == "userprompt":
self.echo(
"[planning-with-files] Multiple plans are available while session "
"isolation is armed. Set PLAN_ID=<slug> for this session; nothing "
"injected."
)
raise Bail()
if ambiguous:
if context == "userprompt":
self.echo(
"[planning-with-files] Multiple plans are available while session "
"isolation is armed. Set PLAN_ID=<slug> for this session; nothing "
"injected."
)
raise Bail()
if not explicit:
nested = []
@@ -1326,6 +1317,23 @@ def inject(context, env=None):
# Twin of resolve-plan-dir.sh (the shared resolver the dispatcher uses).
# --------------------------------------------------------------------------
def plan_is_ambiguous(plan_root, project_root, plan_id=""):
"""A project pointer or mtime cannot bind a session to multiple plans."""
if plan_id:
return False
count = 1 if is_dir(plan_root + "/sessions") and is_file(project_root + "/task_plan.md") else 0
try:
names = os.listdir(plan_root)
except OSError:
names = []
for name in names:
if slug_is_valid(name) and is_file(plan_root + "/" + name + "/task_plan.md"):
count += 1
if count > 1:
return True
return False
def resolve_plan_dir(env=None):
"""Return (spelled, filesystem) paths of the plan directory, or ("", "").
@@ -1360,6 +1368,9 @@ def resolve_plan_dir(env=None):
return found(plan_id)
return ("", "")
if plan_is_ambiguous(fs_root, pin if pin else "."):
return ("", "")
active_file = fs_root + "/.active_plan"
if is_file(active_file):
try:
@@ -1441,6 +1452,8 @@ class ClaudeDispatcher(object):
return (spelled, fs)
if self.env.get("PLAN_ID", "") or self.env.get("PWF_PLAN_ROOT", ""):
return ("", "")
if plan_is_ambiguous(".planning", "."):
return ("", "")
if is_file("task_plan.md"):
return (".", ".")
return ("", "")
@@ -254,6 +254,29 @@ is_within_root() {
# The .active_plan pointer, the newest-by-mtime fallback, and the legacy root
# task_plan.md are cwd GUESSES — only guesses are subject to the nested-root
# conflict check below.
# Shared .active_plan and directory mtime cannot identify this session's plan.
# Check before selection or preflight, even when isolation was never armed.
PLAN_AMBIGUOUS=0
if [ -z "${PLAN_ID:-}" ]; then
PLAN_COUNT=0
if [ -d "${PLAN_PREFIX}.planning/sessions" ] && [ -f "${PLAN_PREFIX}task_plan.md" ]; then
PLAN_COUNT=1
fi
for plan_candidate in "${PLAN_PREFIX}".planning/*/task_plan.md; do
[ -f "$plan_candidate" ] || continue
plan_candidate_dir="${plan_candidate%/task_plan.md}"
slug_is_valid "${plan_candidate_dir##*/}" || continue
PLAN_COUNT=$((PLAN_COUNT + 1))
if [ "$PLAN_COUNT" -gt 1 ]; then PLAN_AMBIGUOUS=1; break; fi
done
fi
if [ "$PLAN_AMBIGUOUS" = "1" ] && { [ "$CONTEXT" = "preflight" ] || [ ! -d "${PLAN_PREFIX}.planning/sessions" ]; }; then
if [ "$CONTEXT" = "userprompt" ]; then
echo "[planning-with-files] Multiple plans are available. Set PLAN_ID=<slug> for this session; nothing injected."
fi
exit 0
fi
RESOLVED=""
SCOPE=""
EXPLICIT=0
@@ -442,27 +465,13 @@ PY
exit 0
fi
# An attachment admits a session but does not select one of several plans.
# When isolation is armed, require PLAN_ID if more than one live same-root
# candidate exists. PWF_PLAN_ROOT selects the project root, not a plan
# within that root.
if [ -z "${PLAN_ID:-}" ]; then
SESSION_PLAN_N=0
[ -f "${PLAN_PREFIX}task_plan.md" ] && SESSION_PLAN_N=1
for candidate in "${PLAN_PREFIX}".planning/*/task_plan.md; do
[ -f "$candidate" ] || continue
candidate_dir="${candidate%/task_plan.md}"
candidate_slug="${candidate_dir##*/}"
slug_is_valid "$candidate_slug" || continue
SESSION_PLAN_N=$((SESSION_PLAN_N + 1))
[ "$SESSION_PLAN_N" -gt 1 ] && break
done
if [ "$SESSION_PLAN_N" -gt 1 ]; then
if [ "$CONTEXT" = "userprompt" ]; then
echo "[planning-with-files] Multiple plans are available while session isolation is armed. Set PLAN_ID=<slug> for this session; nothing injected."
fi
exit 0
# Attachment admits a session but does not select its plan. Preserve the
# attachment-first notice above for sessions that never opted in.
if [ "$PLAN_AMBIGUOUS" = "1" ]; then
if [ "$CONTEXT" = "userprompt" ]; then
echo "[planning-with-files] Multiple plans are available while session isolation is armed. Set PLAN_ID=<slug> for this session; nothing injected."
fi
exit 0
fi
fi
@@ -13,7 +13,8 @@
# junction/symlink escape; slug validation alone blocks textual traversal.
param(
[string]$PlanRoot = (Join-Path (Get-Location) ".planning")
[string]$PlanRoot = (Join-Path (Get-Location) ".planning"),
[switch]$CheckAmbiguity
)
$projectRoot = (Get-Location).Path
@@ -137,6 +138,30 @@ $activeFile = Join-Path $PlanRoot ".active_plan"
# at rc=0. Emptiness is the fail-closed signal on this channel, matching
# resolve-plan-dir.sh and the PWF_PLAN_ROOT pin. An empty $env:PLAN_ID is
# falsy here and still means "unset".
# The optional probe distinguishes ambiguity from a legacy-root fallback.
# Multiple named plans require PLAN_ID even without a sessions directory.
$planCount = 0
if (-not $env:PLAN_ID) {
if ((Test-Path -LiteralPath (Join-Path $PlanRoot "sessions") -PathType Container) -and
(Test-Path -LiteralPath (Join-Path $projectRoot "task_plan.md") -PathType Leaf)) {
$planCount = 1
}
if (Test-Path -LiteralPath $PlanRoot -PathType Container) {
foreach ($entry in (Get-ChildItem -LiteralPath $PlanRoot -Directory -ErrorAction SilentlyContinue)) {
if ((Test-ValidSlug $entry.Name) -and
(Test-Path -LiteralPath (Join-Path $entry.FullName "task_plan.md") -PathType Leaf)) {
$planCount++
if ($planCount -gt 1) { break }
}
}
}
}
if ($CheckAmbiguity) {
if ($planCount -gt 1) { Write-Output "PWF_PLAN_AMBIGUOUS_V1" }
exit 0
}
if ($planCount -gt 1) { exit 0 }
if ($env:PLAN_ID) {
if (Test-ValidSlug $env:PLAN_ID) {
$candidate = Join-Path $PlanRoot $env:PLAN_ID
@@ -15,6 +15,13 @@
set -u
# Optional probe distinguishes ambiguity from the empty legacy-root result.
# Both modes keep stdout data-only and always exit zero.
CHECK_AMBIGUITY=0
if [ "${1:-}" = "--check-ambiguity" ]; then
CHECK_AMBIGUITY=1
shift
fi
PLAN_ROOT="${1:-${PWD}/.planning}"
# --- PWF_PLAN_ROOT: absolute plan-root binding (issue #212). ---
@@ -321,6 +328,29 @@ resolve_latest_dir() {
# the fail-closed signal on this channel, exactly as the PWF_PLAN_ROOT guard
# above already does it; a non-zero status would kill callers running under
# set -e for a condition that is not an internal error.
# A shared pointer or mtime is not a per-session binding (issue #240).
# Count conservatively, just like injection: slug-valid live plan files.
# The legacy root joins the count only when session isolation is armed.
PLAN_AMBIGUOUS=0
if [ -z "${PLAN_ID:-}" ]; then
PLAN_COUNT=0
if [ -d "${PLAN_ROOT}/sessions" ] && [ -f "${PWF_ROOT_PIN:-.}/task_plan.md" ]; then
PLAN_COUNT=1
fi
for plan_candidate in "${PLAN_ROOT}"/*/task_plan.md; do
[ -f "$plan_candidate" ] || continue
plan_candidate_dir="${plan_candidate%/task_plan.md}"
slug_is_valid "${plan_candidate_dir##*/}" || continue
PLAN_COUNT=$((PLAN_COUNT + 1))
if [ "$PLAN_COUNT" -gt 1 ]; then PLAN_AMBIGUOUS=1; break; fi
done
fi
if [ "$CHECK_AMBIGUITY" = "1" ]; then
[ "$PLAN_AMBIGUOUS" = "1" ] && printf '%s\n' 'PWF_PLAN_AMBIGUOUS_V1'
exit 0
fi
[ "$PLAN_AMBIGUOUS" = "1" ] && exit 0
if [ -n "${PLAN_ID:-}" ]; then
resolve_from_env && exit 0
exit 0
+8 -10
View File
@@ -17,8 +17,8 @@ HOOK_DIR = Path(__file__).resolve().parent
_SAFE_LEGACY_SESSION_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}\Z")
_PLAN_SLUG = re.compile(r"[A-Za-z0-9_][A-Za-z0-9._-]*\Z")
SESSION_PLAN_BINDING_NOTICE = (
"[planning-with-files] Multiple plans are available while session isolation "
"is armed. Set PLAN_ID=<slug> for this session; nothing injected."
"[planning-with-files] Multiple plans are available. "
"Set PLAN_ID=<slug> for this session; nothing injected."
)
_DARWIN_SYSTEM_ALIASES = {
Path("/var"): Path("/private/var"),
@@ -223,23 +223,21 @@ def session_plan_requires_binding(root: Path) -> bool:
"""Require PLAN_ID when an attached session can resolve multiple plans.
Session sentinels admit a session to planning context. They do not select a
plan. Once isolation is armed, an unbound session may use legacy resolution
only when the effective root has at most one live plan candidate.
plan. Multiple named plans always require a pin. With isolation armed,
a legacy root plan also counts as a competing candidate.
"""
if os.environ.get("PLAN_ID", ""):
return False
sessions_dir = root / ".planning" / "sessions"
try:
sessions_info = sessions_dir.lstat()
if _is_reparse_or_link(sessions_dir) or not stat.S_ISDIR(sessions_info.st_mode):
return False
except (FileNotFoundError, OSError, RuntimeError):
armed = sessions_dir.exists() or sessions_dir.is_symlink()
planning_dir = root / ".planning"
if not planning_dir.exists():
return False
candidates = 0
try:
if (root / "task_plan.md").is_file():
if armed and (root / "task_plan.md").is_file():
candidates += 1
planning_dir = root / ".planning"
for child in planning_dir.iterdir():
+3
View File
@@ -6,6 +6,9 @@
HOOK_DIR="$(cd "$(dirname "$0")" 2>/dev/null && pwd)"
PLAN_DIR="$(sh "${HOOK_DIR}/resolve-plan-dir.sh" 2>/dev/null)"
if [ -z "$PLAN_DIR" ] && [ "$(sh "${HOOK_DIR}/resolve-plan-dir.sh" --check-ambiguity 2>/dev/null)" = "PWF_PLAN_AMBIGUOUS_V1" ]; then
exit 0
fi
# An explicit PLAN_ID is a binding, not a hint (issue #237). When the shared
# resolver rejected one it emits nothing, and the legacy-root fallback below
# would answer for a plan the operator never named. Stay silent instead; the
+3
View File
@@ -7,6 +7,9 @@
HOOK_DIR="$(cd "$(dirname "$0")" 2>/dev/null && pwd)"
PLAN_DIR="$(sh "${HOOK_DIR}/resolve-plan-dir.sh" 2>/dev/null)"
if [ -z "$PLAN_DIR" ] && [ "$(sh "${HOOK_DIR}/resolve-plan-dir.sh" --check-ambiguity 2>/dev/null)" = "PWF_PLAN_AMBIGUOUS_V1" ]; then
exit 0
fi
# An explicit PLAN_ID is a binding, not a hint (issue #237). When the shared
# resolver rejected one it emits nothing, and the legacy-root fallback below
# would carry the wrong plan through compaction, which is the one moment the
+3
View File
@@ -10,6 +10,9 @@ fi
HOOK_DIR="$(cd "$(dirname "$0")" 2>/dev/null && pwd)"
PLAN_DIR="$(sh "${HOOK_DIR}/resolve-plan-dir.sh" 2>/dev/null)"
if [ -z "$PLAN_DIR" ] && [ "$(sh "${HOOK_DIR}/resolve-plan-dir.sh" --check-ambiguity 2>/dev/null)" = "PWF_PLAN_AMBIGUOUS_V1" ]; then
exit 0
fi
# An explicit PLAN_ID is a binding, not a hint (issue #237). When the shared
# resolver rejected one it emits nothing, and the legacy-root fallback below
# would recite a plan the operator never named on every tool call. Stay silent
+3
View File
@@ -6,6 +6,9 @@
HOOK_DIR="$(cd "$(dirname "$0")" 2>/dev/null && pwd)"
PLAN_DIR="$(sh "${HOOK_DIR}/resolve-plan-dir.sh" 2>/dev/null)"
if [ -z "$PLAN_DIR" ] && [ "$(sh "${HOOK_DIR}/resolve-plan-dir.sh" --check-ambiguity 2>/dev/null)" = "PWF_PLAN_AMBIGUOUS_V1" ]; then
exit 0
fi
# An explicit PLAN_ID is a binding, not a hint (issue #237). When the shared
# resolver rejected one it emits nothing, and the legacy-root fallback below
# would decide whether this run may stop from a plan the operator never named.
+11 -2
View File
@@ -76,6 +76,10 @@ fi
# Resolve and confirm a contained plan before probing or executing any Python
# candidate. No-plan hook fires remain shell-only and silent.
PLAN_DIR="$(sh "${HOOK_DIR}/resolve-plan-dir.sh" 2>/dev/null)"
PLAN_AMBIGUOUS=0
if [ -z "$PLAN_DIR" ] && [ "$(sh "${HOOK_DIR}/resolve-plan-dir.sh" --check-ambiguity 2>/dev/null)" = "PWF_PLAN_AMBIGUOUS_V1" ]; then
PLAN_AMBIGUOUS=1
fi
if [ -n "$PLAN_DIR" ]; then
PLAN_FILE="${PLAN_DIR}/task_plan.md"
PROGRESS_FILE="${PLAN_DIR}/progress.md"
@@ -92,7 +96,7 @@ else
PLAN_FILE="${PLAN_PREFIX}task_plan.md"
PROGRESS_FILE="${PLAN_PREFIX}progress.md"
fi
[ -f "$PLAN_FILE" ] || exit 0
[ -f "$PLAN_FILE" ] || [ "$PLAN_AMBIGUOUS" = "1" ] || exit 0
# --- Re-arm the once-per-turn PostToolUse nudge (issue #239). ---
# One user message is one turn, and this hook fires once per turn, so clearing
@@ -113,7 +117,7 @@ elif [ -n "${HOME:-}" ]; then
else
TURN_ROOT="${TMPDIR:-/tmp}/pwf-turn"
fi
if [ -d "$TURN_ROOT" ]; then
if [ "$PLAN_AMBIGUOUS" = "0" ] && [ -d "$TURN_ROOT" ]; then
case "$TURN_PLAN_FILE" in
/*|[A-Za-z]:*|\\\\*) TURN_KEY_SRC="$TURN_PLAN_FILE" ;;
*) TURN_KEY_SRC="${PWD}/${TURN_PLAN_FILE}" ;;
@@ -160,6 +164,11 @@ case "$PWF_SESSION_ADMISSION" in
;;
esac
if [ "$PLAN_AMBIGUOUS" = "1" ]; then
echo "[planning-with-files] Multiple plans are available. Set PLAN_ID=<slug> for this session; nothing injected."
exit 0
fi
# Plan-id safe-identifier check. Pure-sh case patterns; shared shape with
# resolve-plan-dir.sh, needed below to decide whether PLAN_ID named the plan.
slug_is_valid() {
@@ -404,6 +404,7 @@ function Resolve-PlanFile {
$resolver = Join-Path $PSScriptRoot "resolve-plan-dir.ps1"
if (-not (Test-Path -LiteralPath $resolver -PathType Leaf)) { return $null }
$resolvedDir = @(& $resolver | Where-Object { $_ }) | Select-Object -First 1
if (-not $resolvedDir -and ((& $resolver -CheckAmbiguity) -eq "PWF_PLAN_AMBIGUOUS_V1")) { return $null }
if ($resolvedDir) {
$planFile = Join-Path $resolvedDir "task_plan.md"
return (Resolve-ContainedPlanFile -Candidate $planFile -ExpectedDirectory $resolvedDir)
@@ -45,6 +45,10 @@ resolve_plan_file() {
plan_dir=""
if [ -f "${RESOLVER}" ]; then
plan_dir="$(sh "${RESOLVER}" 2>/dev/null)"
if [ -z "$plan_dir" ] && [ "$(sh "${RESOLVER}" --check-ambiguity 2>/dev/null)" = "PWF_PLAN_AMBIGUOUS_V1" ]; then
printf "[plan-attest] Multiple plans are available. Set PLAN_ID=<slug>; nothing was attested.\n" >&2
return 1
fi
fi
if [ -n "${plan_dir}" ] && [ -f "${plan_dir}/task_plan.md" ]; then
printf "%s\n" "${plan_dir}/task_plan.md"
@@ -35,6 +35,7 @@ if ($PlanFile -ne "") {
try {
$resolvedDir = (& $resolver 2>$null | Select-Object -First 1)
if ($null -eq $resolvedDir) { $resolvedDir = "" }
if (-not $resolvedDir -and ((& $resolver -CheckAmbiguity) -eq "PWF_PLAN_AMBIGUOUS_V1")) { exit 0 }
} catch {
$resolvedDir = ""
}
@@ -56,6 +56,9 @@ else
RESOLVED_DIR=""
if [ -f "${RESOLVER}" ]; then
RESOLVED_DIR="$(sh "${RESOLVER}" 2>/dev/null)"
if [ -z "$RESOLVED_DIR" ] && [ "$(sh "${RESOLVER}" --check-ambiguity 2>/dev/null)" = "PWF_PLAN_AMBIGUOUS_V1" ]; then
exit 0
fi
fi
if [ -n "${RESOLVED_DIR}" ] && [ -f "${RESOLVED_DIR}/task_plan.md" ]; then
PLAN_FILE="${RESOLVED_DIR}/task_plan.md"
@@ -59,12 +59,9 @@ Windows runs the shell chain through Git Bash and nothing else:
one turn-marker slot and one progress-guard slot per plan.
Known, accepted differences from the shell reference:
* Two plan directories with the same whole-second mtime, no .active_plan
and no PLAN_ID: the reference picks the first in the shell's glob order
(locale collation), this twin the first in code-point order. The same
collation difference can change which three of four or more nested
projects the ambiguity notice names. A machine runs one implementation,
so neither choice flips within a session.
* Shell glob order follows locale collation; this twin uses code-point
order. This can change which three of four or more nested projects the
ambiguity notice names.
* BSD sed (macOS) appends a newline to a progress.md whose last line has
none; GNU sed and this twin do not.
* With PWF_PLAN_ROOT set under Git Bash the shell sees the pin as typed
@@ -837,6 +834,16 @@ class Injector(object):
scope = ""
explicit = bool(plan_prefix)
plan_id = env.get("PLAN_ID", "")
ambiguous = plan_is_ambiguous(
plan_prefix + ".planning", plan_root_pin if plan_root_pin else ".", plan_id
)
if ambiguous and (context == "preflight" or not is_dir(plan_prefix + ".planning/sessions")):
if context == "userprompt":
self.echo(
"[planning-with-files] Multiple plans are available. Set PLAN_ID=<slug> "
"for this session; nothing injected."
)
raise Bail()
if plan_id:
if slug_is_valid(plan_id) and is_dir(plan_prefix + ".planning/" + plan_id):
resolved = plan_prefix + ".planning/" + plan_id
@@ -923,30 +930,14 @@ class Injector(object):
"single-session mode."
)
raise Bail()
if not plan_id:
plan_n = 1 if is_file(plan_prefix + "task_plan.md") else 0
try:
names = sorted(os.listdir(plan_prefix + ".planning"))
except OSError:
names = []
for name in names:
if name.startswith("."):
continue
if not is_file(plan_prefix + ".planning/" + name + "/task_plan.md"):
continue
if not slug_is_valid(name):
continue
plan_n += 1
if plan_n > 1:
break
if plan_n > 1:
if context == "userprompt":
self.echo(
"[planning-with-files] Multiple plans are available while session "
"isolation is armed. Set PLAN_ID=<slug> for this session; nothing "
"injected."
)
raise Bail()
if ambiguous:
if context == "userprompt":
self.echo(
"[planning-with-files] Multiple plans are available while session "
"isolation is armed. Set PLAN_ID=<slug> for this session; nothing "
"injected."
)
raise Bail()
if not explicit:
nested = []
@@ -1326,6 +1317,23 @@ def inject(context, env=None):
# Twin of resolve-plan-dir.sh (the shared resolver the dispatcher uses).
# --------------------------------------------------------------------------
def plan_is_ambiguous(plan_root, project_root, plan_id=""):
"""A project pointer or mtime cannot bind a session to multiple plans."""
if plan_id:
return False
count = 1 if is_dir(plan_root + "/sessions") and is_file(project_root + "/task_plan.md") else 0
try:
names = os.listdir(plan_root)
except OSError:
names = []
for name in names:
if slug_is_valid(name) and is_file(plan_root + "/" + name + "/task_plan.md"):
count += 1
if count > 1:
return True
return False
def resolve_plan_dir(env=None):
"""Return (spelled, filesystem) paths of the plan directory, or ("", "").
@@ -1360,6 +1368,9 @@ def resolve_plan_dir(env=None):
return found(plan_id)
return ("", "")
if plan_is_ambiguous(fs_root, pin if pin else "."):
return ("", "")
active_file = fs_root + "/.active_plan"
if is_file(active_file):
try:
@@ -1441,6 +1452,8 @@ class ClaudeDispatcher(object):
return (spelled, fs)
if self.env.get("PLAN_ID", "") or self.env.get("PWF_PLAN_ROOT", ""):
return ("", "")
if plan_is_ambiguous(".planning", "."):
return ("", "")
if is_file("task_plan.md"):
return (".", ".")
return ("", "")
@@ -254,6 +254,29 @@ is_within_root() {
# The .active_plan pointer, the newest-by-mtime fallback, and the legacy root
# task_plan.md are cwd GUESSES — only guesses are subject to the nested-root
# conflict check below.
# Shared .active_plan and directory mtime cannot identify this session's plan.
# Check before selection or preflight, even when isolation was never armed.
PLAN_AMBIGUOUS=0
if [ -z "${PLAN_ID:-}" ]; then
PLAN_COUNT=0
if [ -d "${PLAN_PREFIX}.planning/sessions" ] && [ -f "${PLAN_PREFIX}task_plan.md" ]; then
PLAN_COUNT=1
fi
for plan_candidate in "${PLAN_PREFIX}".planning/*/task_plan.md; do
[ -f "$plan_candidate" ] || continue
plan_candidate_dir="${plan_candidate%/task_plan.md}"
slug_is_valid "${plan_candidate_dir##*/}" || continue
PLAN_COUNT=$((PLAN_COUNT + 1))
if [ "$PLAN_COUNT" -gt 1 ]; then PLAN_AMBIGUOUS=1; break; fi
done
fi
if [ "$PLAN_AMBIGUOUS" = "1" ] && { [ "$CONTEXT" = "preflight" ] || [ ! -d "${PLAN_PREFIX}.planning/sessions" ]; }; then
if [ "$CONTEXT" = "userprompt" ]; then
echo "[planning-with-files] Multiple plans are available. Set PLAN_ID=<slug> for this session; nothing injected."
fi
exit 0
fi
RESOLVED=""
SCOPE=""
EXPLICIT=0
@@ -442,27 +465,13 @@ PY
exit 0
fi
# An attachment admits a session but does not select one of several plans.
# When isolation is armed, require PLAN_ID if more than one live same-root
# candidate exists. PWF_PLAN_ROOT selects the project root, not a plan
# within that root.
if [ -z "${PLAN_ID:-}" ]; then
SESSION_PLAN_N=0
[ -f "${PLAN_PREFIX}task_plan.md" ] && SESSION_PLAN_N=1
for candidate in "${PLAN_PREFIX}".planning/*/task_plan.md; do
[ -f "$candidate" ] || continue
candidate_dir="${candidate%/task_plan.md}"
candidate_slug="${candidate_dir##*/}"
slug_is_valid "$candidate_slug" || continue
SESSION_PLAN_N=$((SESSION_PLAN_N + 1))
[ "$SESSION_PLAN_N" -gt 1 ] && break
done
if [ "$SESSION_PLAN_N" -gt 1 ]; then
if [ "$CONTEXT" = "userprompt" ]; then
echo "[planning-with-files] Multiple plans are available while session isolation is armed. Set PLAN_ID=<slug> for this session; nothing injected."
fi
exit 0
# Attachment admits a session but does not select its plan. Preserve the
# attachment-first notice above for sessions that never opted in.
if [ "$PLAN_AMBIGUOUS" = "1" ]; then
if [ "$CONTEXT" = "userprompt" ]; then
echo "[planning-with-files] Multiple plans are available while session isolation is armed. Set PLAN_ID=<slug> for this session; nothing injected."
fi
exit 0
fi
fi
@@ -13,7 +13,8 @@
# junction/symlink escape; slug validation alone blocks textual traversal.
param(
[string]$PlanRoot = (Join-Path (Get-Location) ".planning")
[string]$PlanRoot = (Join-Path (Get-Location) ".planning"),
[switch]$CheckAmbiguity
)
$projectRoot = (Get-Location).Path
@@ -137,6 +138,30 @@ $activeFile = Join-Path $PlanRoot ".active_plan"
# at rc=0. Emptiness is the fail-closed signal on this channel, matching
# resolve-plan-dir.sh and the PWF_PLAN_ROOT pin. An empty $env:PLAN_ID is
# falsy here and still means "unset".
# The optional probe distinguishes ambiguity from a legacy-root fallback.
# Multiple named plans require PLAN_ID even without a sessions directory.
$planCount = 0
if (-not $env:PLAN_ID) {
if ((Test-Path -LiteralPath (Join-Path $PlanRoot "sessions") -PathType Container) -and
(Test-Path -LiteralPath (Join-Path $projectRoot "task_plan.md") -PathType Leaf)) {
$planCount = 1
}
if (Test-Path -LiteralPath $PlanRoot -PathType Container) {
foreach ($entry in (Get-ChildItem -LiteralPath $PlanRoot -Directory -ErrorAction SilentlyContinue)) {
if ((Test-ValidSlug $entry.Name) -and
(Test-Path -LiteralPath (Join-Path $entry.FullName "task_plan.md") -PathType Leaf)) {
$planCount++
if ($planCount -gt 1) { break }
}
}
}
}
if ($CheckAmbiguity) {
if ($planCount -gt 1) { Write-Output "PWF_PLAN_AMBIGUOUS_V1" }
exit 0
}
if ($planCount -gt 1) { exit 0 }
if ($env:PLAN_ID) {
if (Test-ValidSlug $env:PLAN_ID) {
$candidate = Join-Path $PlanRoot $env:PLAN_ID
@@ -15,6 +15,13 @@
set -u
# Optional probe distinguishes ambiguity from the empty legacy-root result.
# Both modes keep stdout data-only and always exit zero.
CHECK_AMBIGUITY=0
if [ "${1:-}" = "--check-ambiguity" ]; then
CHECK_AMBIGUITY=1
shift
fi
PLAN_ROOT="${1:-${PWD}/.planning}"
# --- PWF_PLAN_ROOT: absolute plan-root binding (issue #212). ---
@@ -321,6 +328,29 @@ resolve_latest_dir() {
# the fail-closed signal on this channel, exactly as the PWF_PLAN_ROOT guard
# above already does it; a non-zero status would kill callers running under
# set -e for a condition that is not an internal error.
# A shared pointer or mtime is not a per-session binding (issue #240).
# Count conservatively, just like injection: slug-valid live plan files.
# The legacy root joins the count only when session isolation is armed.
PLAN_AMBIGUOUS=0
if [ -z "${PLAN_ID:-}" ]; then
PLAN_COUNT=0
if [ -d "${PLAN_ROOT}/sessions" ] && [ -f "${PWF_ROOT_PIN:-.}/task_plan.md" ]; then
PLAN_COUNT=1
fi
for plan_candidate in "${PLAN_ROOT}"/*/task_plan.md; do
[ -f "$plan_candidate" ] || continue
plan_candidate_dir="${plan_candidate%/task_plan.md}"
slug_is_valid "${plan_candidate_dir##*/}" || continue
PLAN_COUNT=$((PLAN_COUNT + 1))
if [ "$PLAN_COUNT" -gt 1 ]; then PLAN_AMBIGUOUS=1; break; fi
done
fi
if [ "$CHECK_AMBIGUITY" = "1" ]; then
[ "$PLAN_AMBIGUOUS" = "1" ] && printf '%s\n' 'PWF_PLAN_AMBIGUOUS_V1'
exit 0
fi
[ "$PLAN_AMBIGUOUS" = "1" ] && exit 0
if [ -n "${PLAN_ID:-}" ]; then
resolve_from_env && exit 0
exit 0
@@ -404,6 +404,7 @@ function Resolve-PlanFile {
$resolver = Join-Path $PSScriptRoot "resolve-plan-dir.ps1"
if (-not (Test-Path -LiteralPath $resolver -PathType Leaf)) { return $null }
$resolvedDir = @(& $resolver | Where-Object { $_ }) | Select-Object -First 1
if (-not $resolvedDir -and ((& $resolver -CheckAmbiguity) -eq "PWF_PLAN_AMBIGUOUS_V1")) { return $null }
if ($resolvedDir) {
$planFile = Join-Path $resolvedDir "task_plan.md"
return (Resolve-ContainedPlanFile -Candidate $planFile -ExpectedDirectory $resolvedDir)
@@ -45,6 +45,10 @@ resolve_plan_file() {
plan_dir=""
if [ -f "${RESOLVER}" ]; then
plan_dir="$(sh "${RESOLVER}" 2>/dev/null)"
if [ -z "$plan_dir" ] && [ "$(sh "${RESOLVER}" --check-ambiguity 2>/dev/null)" = "PWF_PLAN_AMBIGUOUS_V1" ]; then
printf "[plan-attest] Multiple plans are available. Set PLAN_ID=<slug>; nothing was attested.\n" >&2
return 1
fi
fi
if [ -n "${plan_dir}" ] && [ -f "${plan_dir}/task_plan.md" ]; then
printf "%s\n" "${plan_dir}/task_plan.md"
@@ -35,6 +35,7 @@ if ($PlanFile -ne "") {
try {
$resolvedDir = (& $resolver 2>$null | Select-Object -First 1)
if ($null -eq $resolvedDir) { $resolvedDir = "" }
if (-not $resolvedDir -and ((& $resolver -CheckAmbiguity) -eq "PWF_PLAN_AMBIGUOUS_V1")) { exit 0 }
} catch {
$resolvedDir = ""
}
@@ -56,6 +56,9 @@ else
RESOLVED_DIR=""
if [ -f "${RESOLVER}" ]; then
RESOLVED_DIR="$(sh "${RESOLVER}" 2>/dev/null)"
if [ -z "$RESOLVED_DIR" ] && [ "$(sh "${RESOLVER}" --check-ambiguity 2>/dev/null)" = "PWF_PLAN_AMBIGUOUS_V1" ]; then
exit 0
fi
fi
if [ -n "${RESOLVED_DIR}" ] && [ -f "${RESOLVED_DIR}/task_plan.md" ]; then
PLAN_FILE="${RESOLVED_DIR}/task_plan.md"
@@ -13,7 +13,8 @@
# junction/symlink escape; slug validation alone blocks textual traversal.
param(
[string]$PlanRoot = (Join-Path (Get-Location) ".planning")
[string]$PlanRoot = (Join-Path (Get-Location) ".planning"),
[switch]$CheckAmbiguity
)
$projectRoot = (Get-Location).Path
@@ -137,6 +138,30 @@ $activeFile = Join-Path $PlanRoot ".active_plan"
# at rc=0. Emptiness is the fail-closed signal on this channel, matching
# resolve-plan-dir.sh and the PWF_PLAN_ROOT pin. An empty $env:PLAN_ID is
# falsy here and still means "unset".
# The optional probe distinguishes ambiguity from a legacy-root fallback.
# Multiple named plans require PLAN_ID even without a sessions directory.
$planCount = 0
if (-not $env:PLAN_ID) {
if ((Test-Path -LiteralPath (Join-Path $PlanRoot "sessions") -PathType Container) -and
(Test-Path -LiteralPath (Join-Path $projectRoot "task_plan.md") -PathType Leaf)) {
$planCount = 1
}
if (Test-Path -LiteralPath $PlanRoot -PathType Container) {
foreach ($entry in (Get-ChildItem -LiteralPath $PlanRoot -Directory -ErrorAction SilentlyContinue)) {
if ((Test-ValidSlug $entry.Name) -and
(Test-Path -LiteralPath (Join-Path $entry.FullName "task_plan.md") -PathType Leaf)) {
$planCount++
if ($planCount -gt 1) { break }
}
}
}
}
if ($CheckAmbiguity) {
if ($planCount -gt 1) { Write-Output "PWF_PLAN_AMBIGUOUS_V1" }
exit 0
}
if ($planCount -gt 1) { exit 0 }
if ($env:PLAN_ID) {
if (Test-ValidSlug $env:PLAN_ID) {
$candidate = Join-Path $PlanRoot $env:PLAN_ID
@@ -15,6 +15,13 @@
set -u
# Optional probe distinguishes ambiguity from the empty legacy-root result.
# Both modes keep stdout data-only and always exit zero.
CHECK_AMBIGUITY=0
if [ "${1:-}" = "--check-ambiguity" ]; then
CHECK_AMBIGUITY=1
shift
fi
PLAN_ROOT="${1:-${PWD}/.planning}"
# --- PWF_PLAN_ROOT: absolute plan-root binding (issue #212). ---
@@ -321,6 +328,29 @@ resolve_latest_dir() {
# the fail-closed signal on this channel, exactly as the PWF_PLAN_ROOT guard
# above already does it; a non-zero status would kill callers running under
# set -e for a condition that is not an internal error.
# A shared pointer or mtime is not a per-session binding (issue #240).
# Count conservatively, just like injection: slug-valid live plan files.
# The legacy root joins the count only when session isolation is armed.
PLAN_AMBIGUOUS=0
if [ -z "${PLAN_ID:-}" ]; then
PLAN_COUNT=0
if [ -d "${PLAN_ROOT}/sessions" ] && [ -f "${PWF_ROOT_PIN:-.}/task_plan.md" ]; then
PLAN_COUNT=1
fi
for plan_candidate in "${PLAN_ROOT}"/*/task_plan.md; do
[ -f "$plan_candidate" ] || continue
plan_candidate_dir="${plan_candidate%/task_plan.md}"
slug_is_valid "${plan_candidate_dir##*/}" || continue
PLAN_COUNT=$((PLAN_COUNT + 1))
if [ "$PLAN_COUNT" -gt 1 ]; then PLAN_AMBIGUOUS=1; break; fi
done
fi
if [ "$CHECK_AMBIGUITY" = "1" ]; then
[ "$PLAN_AMBIGUOUS" = "1" ] && printf '%s\n' 'PWF_PLAN_AMBIGUOUS_V1'
exit 0
fi
[ "$PLAN_AMBIGUOUS" = "1" ] && exit 0
if [ -n "${PLAN_ID:-}" ]; then
resolve_from_env && exit 0
exit 0
@@ -404,6 +404,7 @@ function Resolve-PlanFile {
$resolver = Join-Path $PSScriptRoot "resolve-plan-dir.ps1"
if (-not (Test-Path -LiteralPath $resolver -PathType Leaf)) { return $null }
$resolvedDir = @(& $resolver | Where-Object { $_ }) | Select-Object -First 1
if (-not $resolvedDir -and ((& $resolver -CheckAmbiguity) -eq "PWF_PLAN_AMBIGUOUS_V1")) { return $null }
if ($resolvedDir) {
$planFile = Join-Path $resolvedDir "task_plan.md"
return (Resolve-ContainedPlanFile -Candidate $planFile -ExpectedDirectory $resolvedDir)
@@ -45,6 +45,10 @@ resolve_plan_file() {
plan_dir=""
if [ -f "${RESOLVER}" ]; then
plan_dir="$(sh "${RESOLVER}" 2>/dev/null)"
if [ -z "$plan_dir" ] && [ "$(sh "${RESOLVER}" --check-ambiguity 2>/dev/null)" = "PWF_PLAN_AMBIGUOUS_V1" ]; then
printf "[plan-attest] Multiple plans are available. Set PLAN_ID=<slug>; nothing was attested.\n" >&2
return 1
fi
fi
if [ -n "${plan_dir}" ] && [ -f "${plan_dir}/task_plan.md" ]; then
printf "%s\n" "${plan_dir}/task_plan.md"
@@ -35,6 +35,7 @@ if ($PlanFile -ne "") {
try {
$resolvedDir = (& $resolver 2>$null | Select-Object -First 1)
if ($null -eq $resolvedDir) { $resolvedDir = "" }
if (-not $resolvedDir -and ((& $resolver -CheckAmbiguity) -eq "PWF_PLAN_AMBIGUOUS_V1")) { exit 0 }
} catch {
$resolvedDir = ""
}
@@ -56,6 +56,9 @@ else
RESOLVED_DIR=""
if [ -f "${RESOLVER}" ]; then
RESOLVED_DIR="$(sh "${RESOLVER}" 2>/dev/null)"
if [ -z "$RESOLVED_DIR" ] && [ "$(sh "${RESOLVER}" --check-ambiguity 2>/dev/null)" = "PWF_PLAN_AMBIGUOUS_V1" ]; then
exit 0
fi
fi
if [ -n "${RESOLVED_DIR}" ] && [ -f "${RESOLVED_DIR}/task_plan.md" ]; then
PLAN_FILE="${RESOLVED_DIR}/task_plan.md"
@@ -59,12 +59,9 @@ Windows runs the shell chain through Git Bash and nothing else:
one turn-marker slot and one progress-guard slot per plan.
Known, accepted differences from the shell reference:
* Two plan directories with the same whole-second mtime, no .active_plan
and no PLAN_ID: the reference picks the first in the shell's glob order
(locale collation), this twin the first in code-point order. The same
collation difference can change which three of four or more nested
projects the ambiguity notice names. A machine runs one implementation,
so neither choice flips within a session.
* Shell glob order follows locale collation; this twin uses code-point
order. This can change which three of four or more nested projects the
ambiguity notice names.
* BSD sed (macOS) appends a newline to a progress.md whose last line has
none; GNU sed and this twin do not.
* With PWF_PLAN_ROOT set under Git Bash the shell sees the pin as typed
@@ -837,6 +834,16 @@ class Injector(object):
scope = ""
explicit = bool(plan_prefix)
plan_id = env.get("PLAN_ID", "")
ambiguous = plan_is_ambiguous(
plan_prefix + ".planning", plan_root_pin if plan_root_pin else ".", plan_id
)
if ambiguous and (context == "preflight" or not is_dir(plan_prefix + ".planning/sessions")):
if context == "userprompt":
self.echo(
"[planning-with-files] Multiple plans are available. Set PLAN_ID=<slug> "
"for this session; nothing injected."
)
raise Bail()
if plan_id:
if slug_is_valid(plan_id) and is_dir(plan_prefix + ".planning/" + plan_id):
resolved = plan_prefix + ".planning/" + plan_id
@@ -923,30 +930,14 @@ class Injector(object):
"single-session mode."
)
raise Bail()
if not plan_id:
plan_n = 1 if is_file(plan_prefix + "task_plan.md") else 0
try:
names = sorted(os.listdir(plan_prefix + ".planning"))
except OSError:
names = []
for name in names:
if name.startswith("."):
continue
if not is_file(plan_prefix + ".planning/" + name + "/task_plan.md"):
continue
if not slug_is_valid(name):
continue
plan_n += 1
if plan_n > 1:
break
if plan_n > 1:
if context == "userprompt":
self.echo(
"[planning-with-files] Multiple plans are available while session "
"isolation is armed. Set PLAN_ID=<slug> for this session; nothing "
"injected."
)
raise Bail()
if ambiguous:
if context == "userprompt":
self.echo(
"[planning-with-files] Multiple plans are available while session "
"isolation is armed. Set PLAN_ID=<slug> for this session; nothing "
"injected."
)
raise Bail()
if not explicit:
nested = []
@@ -1326,6 +1317,23 @@ def inject(context, env=None):
# Twin of resolve-plan-dir.sh (the shared resolver the dispatcher uses).
# --------------------------------------------------------------------------
def plan_is_ambiguous(plan_root, project_root, plan_id=""):
"""A project pointer or mtime cannot bind a session to multiple plans."""
if plan_id:
return False
count = 1 if is_dir(plan_root + "/sessions") and is_file(project_root + "/task_plan.md") else 0
try:
names = os.listdir(plan_root)
except OSError:
names = []
for name in names:
if slug_is_valid(name) and is_file(plan_root + "/" + name + "/task_plan.md"):
count += 1
if count > 1:
return True
return False
def resolve_plan_dir(env=None):
"""Return (spelled, filesystem) paths of the plan directory, or ("", "").
@@ -1360,6 +1368,9 @@ def resolve_plan_dir(env=None):
return found(plan_id)
return ("", "")
if plan_is_ambiguous(fs_root, pin if pin else "."):
return ("", "")
active_file = fs_root + "/.active_plan"
if is_file(active_file):
try:
@@ -1441,6 +1452,8 @@ class ClaudeDispatcher(object):
return (spelled, fs)
if self.env.get("PLAN_ID", "") or self.env.get("PWF_PLAN_ROOT", ""):
return ("", "")
if plan_is_ambiguous(".planning", "."):
return ("", "")
if is_file("task_plan.md"):
return (".", ".")
return ("", "")
@@ -254,6 +254,29 @@ is_within_root() {
# The .active_plan pointer, the newest-by-mtime fallback, and the legacy root
# task_plan.md are cwd GUESSES — only guesses are subject to the nested-root
# conflict check below.
# Shared .active_plan and directory mtime cannot identify this session's plan.
# Check before selection or preflight, even when isolation was never armed.
PLAN_AMBIGUOUS=0
if [ -z "${PLAN_ID:-}" ]; then
PLAN_COUNT=0
if [ -d "${PLAN_PREFIX}.planning/sessions" ] && [ -f "${PLAN_PREFIX}task_plan.md" ]; then
PLAN_COUNT=1
fi
for plan_candidate in "${PLAN_PREFIX}".planning/*/task_plan.md; do
[ -f "$plan_candidate" ] || continue
plan_candidate_dir="${plan_candidate%/task_plan.md}"
slug_is_valid "${plan_candidate_dir##*/}" || continue
PLAN_COUNT=$((PLAN_COUNT + 1))
if [ "$PLAN_COUNT" -gt 1 ]; then PLAN_AMBIGUOUS=1; break; fi
done
fi
if [ "$PLAN_AMBIGUOUS" = "1" ] && { [ "$CONTEXT" = "preflight" ] || [ ! -d "${PLAN_PREFIX}.planning/sessions" ]; }; then
if [ "$CONTEXT" = "userprompt" ]; then
echo "[planning-with-files] Multiple plans are available. Set PLAN_ID=<slug> for this session; nothing injected."
fi
exit 0
fi
RESOLVED=""
SCOPE=""
EXPLICIT=0
@@ -442,27 +465,13 @@ PY
exit 0
fi
# An attachment admits a session but does not select one of several plans.
# When isolation is armed, require PLAN_ID if more than one live same-root
# candidate exists. PWF_PLAN_ROOT selects the project root, not a plan
# within that root.
if [ -z "${PLAN_ID:-}" ]; then
SESSION_PLAN_N=0
[ -f "${PLAN_PREFIX}task_plan.md" ] && SESSION_PLAN_N=1
for candidate in "${PLAN_PREFIX}".planning/*/task_plan.md; do
[ -f "$candidate" ] || continue
candidate_dir="${candidate%/task_plan.md}"
candidate_slug="${candidate_dir##*/}"
slug_is_valid "$candidate_slug" || continue
SESSION_PLAN_N=$((SESSION_PLAN_N + 1))
[ "$SESSION_PLAN_N" -gt 1 ] && break
done
if [ "$SESSION_PLAN_N" -gt 1 ]; then
if [ "$CONTEXT" = "userprompt" ]; then
echo "[planning-with-files] Multiple plans are available while session isolation is armed. Set PLAN_ID=<slug> for this session; nothing injected."
fi
exit 0
# Attachment admits a session but does not select its plan. Preserve the
# attachment-first notice above for sessions that never opted in.
if [ "$PLAN_AMBIGUOUS" = "1" ]; then
if [ "$CONTEXT" = "userprompt" ]; then
echo "[planning-with-files] Multiple plans are available while session isolation is armed. Set PLAN_ID=<slug> for this session; nothing injected."
fi
exit 0
fi
fi
@@ -13,7 +13,8 @@
# junction/symlink escape; slug validation alone blocks textual traversal.
param(
[string]$PlanRoot = (Join-Path (Get-Location) ".planning")
[string]$PlanRoot = (Join-Path (Get-Location) ".planning"),
[switch]$CheckAmbiguity
)
$projectRoot = (Get-Location).Path
@@ -137,6 +138,30 @@ $activeFile = Join-Path $PlanRoot ".active_plan"
# at rc=0. Emptiness is the fail-closed signal on this channel, matching
# resolve-plan-dir.sh and the PWF_PLAN_ROOT pin. An empty $env:PLAN_ID is
# falsy here and still means "unset".
# The optional probe distinguishes ambiguity from a legacy-root fallback.
# Multiple named plans require PLAN_ID even without a sessions directory.
$planCount = 0
if (-not $env:PLAN_ID) {
if ((Test-Path -LiteralPath (Join-Path $PlanRoot "sessions") -PathType Container) -and
(Test-Path -LiteralPath (Join-Path $projectRoot "task_plan.md") -PathType Leaf)) {
$planCount = 1
}
if (Test-Path -LiteralPath $PlanRoot -PathType Container) {
foreach ($entry in (Get-ChildItem -LiteralPath $PlanRoot -Directory -ErrorAction SilentlyContinue)) {
if ((Test-ValidSlug $entry.Name) -and
(Test-Path -LiteralPath (Join-Path $entry.FullName "task_plan.md") -PathType Leaf)) {
$planCount++
if ($planCount -gt 1) { break }
}
}
}
}
if ($CheckAmbiguity) {
if ($planCount -gt 1) { Write-Output "PWF_PLAN_AMBIGUOUS_V1" }
exit 0
}
if ($planCount -gt 1) { exit 0 }
if ($env:PLAN_ID) {
if (Test-ValidSlug $env:PLAN_ID) {
$candidate = Join-Path $PlanRoot $env:PLAN_ID
@@ -15,6 +15,13 @@
set -u
# Optional probe distinguishes ambiguity from the empty legacy-root result.
# Both modes keep stdout data-only and always exit zero.
CHECK_AMBIGUITY=0
if [ "${1:-}" = "--check-ambiguity" ]; then
CHECK_AMBIGUITY=1
shift
fi
PLAN_ROOT="${1:-${PWD}/.planning}"
# --- PWF_PLAN_ROOT: absolute plan-root binding (issue #212). ---
@@ -321,6 +328,29 @@ resolve_latest_dir() {
# the fail-closed signal on this channel, exactly as the PWF_PLAN_ROOT guard
# above already does it; a non-zero status would kill callers running under
# set -e for a condition that is not an internal error.
# A shared pointer or mtime is not a per-session binding (issue #240).
# Count conservatively, just like injection: slug-valid live plan files.
# The legacy root joins the count only when session isolation is armed.
PLAN_AMBIGUOUS=0
if [ -z "${PLAN_ID:-}" ]; then
PLAN_COUNT=0
if [ -d "${PLAN_ROOT}/sessions" ] && [ -f "${PWF_ROOT_PIN:-.}/task_plan.md" ]; then
PLAN_COUNT=1
fi
for plan_candidate in "${PLAN_ROOT}"/*/task_plan.md; do
[ -f "$plan_candidate" ] || continue
plan_candidate_dir="${plan_candidate%/task_plan.md}"
slug_is_valid "${plan_candidate_dir##*/}" || continue
PLAN_COUNT=$((PLAN_COUNT + 1))
if [ "$PLAN_COUNT" -gt 1 ]; then PLAN_AMBIGUOUS=1; break; fi
done
fi
if [ "$CHECK_AMBIGUITY" = "1" ]; then
[ "$PLAN_AMBIGUOUS" = "1" ] && printf '%s\n' 'PWF_PLAN_AMBIGUOUS_V1'
exit 0
fi
[ "$PLAN_AMBIGUOUS" = "1" ] && exit 0
if [ -n "${PLAN_ID:-}" ]; then
resolve_from_env && exit 0
exit 0
@@ -404,6 +404,7 @@ function Resolve-PlanFile {
$resolver = Join-Path $PSScriptRoot "resolve-plan-dir.ps1"
if (-not (Test-Path -LiteralPath $resolver -PathType Leaf)) { return $null }
$resolvedDir = @(& $resolver | Where-Object { $_ }) | Select-Object -First 1
if (-not $resolvedDir -and ((& $resolver -CheckAmbiguity) -eq "PWF_PLAN_AMBIGUOUS_V1")) { return $null }
if ($resolvedDir) {
$planFile = Join-Path $resolvedDir "task_plan.md"
return (Resolve-ContainedPlanFile -Candidate $planFile -ExpectedDirectory $resolvedDir)
@@ -45,6 +45,10 @@ resolve_plan_file() {
plan_dir=""
if [ -f "${RESOLVER}" ]; then
plan_dir="$(sh "${RESOLVER}" 2>/dev/null)"
if [ -z "$plan_dir" ] && [ "$(sh "${RESOLVER}" --check-ambiguity 2>/dev/null)" = "PWF_PLAN_AMBIGUOUS_V1" ]; then
printf "[plan-attest] Multiple plans are available. Set PLAN_ID=<slug>; nothing was attested.\n" >&2
return 1
fi
fi
if [ -n "${plan_dir}" ] && [ -f "${plan_dir}/task_plan.md" ]; then
printf "%s\n" "${plan_dir}/task_plan.md"
@@ -35,6 +35,7 @@ if ($PlanFile -ne "") {
try {
$resolvedDir = (& $resolver 2>$null | Select-Object -First 1)
if ($null -eq $resolvedDir) { $resolvedDir = "" }
if (-not $resolvedDir -and ((& $resolver -CheckAmbiguity) -eq "PWF_PLAN_AMBIGUOUS_V1")) { exit 0 }
} catch {
$resolvedDir = ""
}
@@ -56,6 +56,9 @@ else
RESOLVED_DIR=""
if [ -f "${RESOLVER}" ]; then
RESOLVED_DIR="$(sh "${RESOLVER}" 2>/dev/null)"
if [ -z "$RESOLVED_DIR" ] && [ "$(sh "${RESOLVER}" --check-ambiguity 2>/dev/null)" = "PWF_PLAN_AMBIGUOUS_V1" ]; then
exit 0
fi
fi
if [ -n "${RESOLVED_DIR}" ] && [ -f "${RESOLVED_DIR}/task_plan.md" ]; then
PLAN_FILE="${RESOLVED_DIR}/task_plan.md"
@@ -59,12 +59,9 @@ Windows runs the shell chain through Git Bash and nothing else:
one turn-marker slot and one progress-guard slot per plan.
Known, accepted differences from the shell reference:
* Two plan directories with the same whole-second mtime, no .active_plan
and no PLAN_ID: the reference picks the first in the shell's glob order
(locale collation), this twin the first in code-point order. The same
collation difference can change which three of four or more nested
projects the ambiguity notice names. A machine runs one implementation,
so neither choice flips within a session.
* Shell glob order follows locale collation; this twin uses code-point
order. This can change which three of four or more nested projects the
ambiguity notice names.
* BSD sed (macOS) appends a newline to a progress.md whose last line has
none; GNU sed and this twin do not.
* With PWF_PLAN_ROOT set under Git Bash the shell sees the pin as typed
@@ -837,6 +834,16 @@ class Injector(object):
scope = ""
explicit = bool(plan_prefix)
plan_id = env.get("PLAN_ID", "")
ambiguous = plan_is_ambiguous(
plan_prefix + ".planning", plan_root_pin if plan_root_pin else ".", plan_id
)
if ambiguous and (context == "preflight" or not is_dir(plan_prefix + ".planning/sessions")):
if context == "userprompt":
self.echo(
"[planning-with-files] Multiple plans are available. Set PLAN_ID=<slug> "
"for this session; nothing injected."
)
raise Bail()
if plan_id:
if slug_is_valid(plan_id) and is_dir(plan_prefix + ".planning/" + plan_id):
resolved = plan_prefix + ".planning/" + plan_id
@@ -923,30 +930,14 @@ class Injector(object):
"single-session mode."
)
raise Bail()
if not plan_id:
plan_n = 1 if is_file(plan_prefix + "task_plan.md") else 0
try:
names = sorted(os.listdir(plan_prefix + ".planning"))
except OSError:
names = []
for name in names:
if name.startswith("."):
continue
if not is_file(plan_prefix + ".planning/" + name + "/task_plan.md"):
continue
if not slug_is_valid(name):
continue
plan_n += 1
if plan_n > 1:
break
if plan_n > 1:
if context == "userprompt":
self.echo(
"[planning-with-files] Multiple plans are available while session "
"isolation is armed. Set PLAN_ID=<slug> for this session; nothing "
"injected."
)
raise Bail()
if ambiguous:
if context == "userprompt":
self.echo(
"[planning-with-files] Multiple plans are available while session "
"isolation is armed. Set PLAN_ID=<slug> for this session; nothing "
"injected."
)
raise Bail()
if not explicit:
nested = []
@@ -1326,6 +1317,23 @@ def inject(context, env=None):
# Twin of resolve-plan-dir.sh (the shared resolver the dispatcher uses).
# --------------------------------------------------------------------------
def plan_is_ambiguous(plan_root, project_root, plan_id=""):
"""A project pointer or mtime cannot bind a session to multiple plans."""
if plan_id:
return False
count = 1 if is_dir(plan_root + "/sessions") and is_file(project_root + "/task_plan.md") else 0
try:
names = os.listdir(plan_root)
except OSError:
names = []
for name in names:
if slug_is_valid(name) and is_file(plan_root + "/" + name + "/task_plan.md"):
count += 1
if count > 1:
return True
return False
def resolve_plan_dir(env=None):
"""Return (spelled, filesystem) paths of the plan directory, or ("", "").
@@ -1360,6 +1368,9 @@ def resolve_plan_dir(env=None):
return found(plan_id)
return ("", "")
if plan_is_ambiguous(fs_root, pin if pin else "."):
return ("", "")
active_file = fs_root + "/.active_plan"
if is_file(active_file):
try:
@@ -1441,6 +1452,8 @@ class ClaudeDispatcher(object):
return (spelled, fs)
if self.env.get("PLAN_ID", "") or self.env.get("PWF_PLAN_ROOT", ""):
return ("", "")
if plan_is_ambiguous(".planning", "."):
return ("", "")
if is_file("task_plan.md"):
return (".", ".")
return ("", "")
@@ -254,6 +254,29 @@ is_within_root() {
# The .active_plan pointer, the newest-by-mtime fallback, and the legacy root
# task_plan.md are cwd GUESSES — only guesses are subject to the nested-root
# conflict check below.
# Shared .active_plan and directory mtime cannot identify this session's plan.
# Check before selection or preflight, even when isolation was never armed.
PLAN_AMBIGUOUS=0
if [ -z "${PLAN_ID:-}" ]; then
PLAN_COUNT=0
if [ -d "${PLAN_PREFIX}.planning/sessions" ] && [ -f "${PLAN_PREFIX}task_plan.md" ]; then
PLAN_COUNT=1
fi
for plan_candidate in "${PLAN_PREFIX}".planning/*/task_plan.md; do
[ -f "$plan_candidate" ] || continue
plan_candidate_dir="${plan_candidate%/task_plan.md}"
slug_is_valid "${plan_candidate_dir##*/}" || continue
PLAN_COUNT=$((PLAN_COUNT + 1))
if [ "$PLAN_COUNT" -gt 1 ]; then PLAN_AMBIGUOUS=1; break; fi
done
fi
if [ "$PLAN_AMBIGUOUS" = "1" ] && { [ "$CONTEXT" = "preflight" ] || [ ! -d "${PLAN_PREFIX}.planning/sessions" ]; }; then
if [ "$CONTEXT" = "userprompt" ]; then
echo "[planning-with-files] Multiple plans are available. Set PLAN_ID=<slug> for this session; nothing injected."
fi
exit 0
fi
RESOLVED=""
SCOPE=""
EXPLICIT=0
@@ -442,27 +465,13 @@ PY
exit 0
fi
# An attachment admits a session but does not select one of several plans.
# When isolation is armed, require PLAN_ID if more than one live same-root
# candidate exists. PWF_PLAN_ROOT selects the project root, not a plan
# within that root.
if [ -z "${PLAN_ID:-}" ]; then
SESSION_PLAN_N=0
[ -f "${PLAN_PREFIX}task_plan.md" ] && SESSION_PLAN_N=1
for candidate in "${PLAN_PREFIX}".planning/*/task_plan.md; do
[ -f "$candidate" ] || continue
candidate_dir="${candidate%/task_plan.md}"
candidate_slug="${candidate_dir##*/}"
slug_is_valid "$candidate_slug" || continue
SESSION_PLAN_N=$((SESSION_PLAN_N + 1))
[ "$SESSION_PLAN_N" -gt 1 ] && break
done
if [ "$SESSION_PLAN_N" -gt 1 ]; then
if [ "$CONTEXT" = "userprompt" ]; then
echo "[planning-with-files] Multiple plans are available while session isolation is armed. Set PLAN_ID=<slug> for this session; nothing injected."
fi
exit 0
# Attachment admits a session but does not select its plan. Preserve the
# attachment-first notice above for sessions that never opted in.
if [ "$PLAN_AMBIGUOUS" = "1" ]; then
if [ "$CONTEXT" = "userprompt" ]; then
echo "[planning-with-files] Multiple plans are available while session isolation is armed. Set PLAN_ID=<slug> for this session; nothing injected."
fi
exit 0
fi
fi
@@ -13,7 +13,8 @@
# junction/symlink escape; slug validation alone blocks textual traversal.
param(
[string]$PlanRoot = (Join-Path (Get-Location) ".planning")
[string]$PlanRoot = (Join-Path (Get-Location) ".planning"),
[switch]$CheckAmbiguity
)
$projectRoot = (Get-Location).Path
@@ -137,6 +138,30 @@ $activeFile = Join-Path $PlanRoot ".active_plan"
# at rc=0. Emptiness is the fail-closed signal on this channel, matching
# resolve-plan-dir.sh and the PWF_PLAN_ROOT pin. An empty $env:PLAN_ID is
# falsy here and still means "unset".
# The optional probe distinguishes ambiguity from a legacy-root fallback.
# Multiple named plans require PLAN_ID even without a sessions directory.
$planCount = 0
if (-not $env:PLAN_ID) {
if ((Test-Path -LiteralPath (Join-Path $PlanRoot "sessions") -PathType Container) -and
(Test-Path -LiteralPath (Join-Path $projectRoot "task_plan.md") -PathType Leaf)) {
$planCount = 1
}
if (Test-Path -LiteralPath $PlanRoot -PathType Container) {
foreach ($entry in (Get-ChildItem -LiteralPath $PlanRoot -Directory -ErrorAction SilentlyContinue)) {
if ((Test-ValidSlug $entry.Name) -and
(Test-Path -LiteralPath (Join-Path $entry.FullName "task_plan.md") -PathType Leaf)) {
$planCount++
if ($planCount -gt 1) { break }
}
}
}
}
if ($CheckAmbiguity) {
if ($planCount -gt 1) { Write-Output "PWF_PLAN_AMBIGUOUS_V1" }
exit 0
}
if ($planCount -gt 1) { exit 0 }
if ($env:PLAN_ID) {
if (Test-ValidSlug $env:PLAN_ID) {
$candidate = Join-Path $PlanRoot $env:PLAN_ID
@@ -15,6 +15,13 @@
set -u
# Optional probe distinguishes ambiguity from the empty legacy-root result.
# Both modes keep stdout data-only and always exit zero.
CHECK_AMBIGUITY=0
if [ "${1:-}" = "--check-ambiguity" ]; then
CHECK_AMBIGUITY=1
shift
fi
PLAN_ROOT="${1:-${PWD}/.planning}"
# --- PWF_PLAN_ROOT: absolute plan-root binding (issue #212). ---
@@ -321,6 +328,29 @@ resolve_latest_dir() {
# the fail-closed signal on this channel, exactly as the PWF_PLAN_ROOT guard
# above already does it; a non-zero status would kill callers running under
# set -e for a condition that is not an internal error.
# A shared pointer or mtime is not a per-session binding (issue #240).
# Count conservatively, just like injection: slug-valid live plan files.
# The legacy root joins the count only when session isolation is armed.
PLAN_AMBIGUOUS=0
if [ -z "${PLAN_ID:-}" ]; then
PLAN_COUNT=0
if [ -d "${PLAN_ROOT}/sessions" ] && [ -f "${PWF_ROOT_PIN:-.}/task_plan.md" ]; then
PLAN_COUNT=1
fi
for plan_candidate in "${PLAN_ROOT}"/*/task_plan.md; do
[ -f "$plan_candidate" ] || continue
plan_candidate_dir="${plan_candidate%/task_plan.md}"
slug_is_valid "${plan_candidate_dir##*/}" || continue
PLAN_COUNT=$((PLAN_COUNT + 1))
if [ "$PLAN_COUNT" -gt 1 ]; then PLAN_AMBIGUOUS=1; break; fi
done
fi
if [ "$CHECK_AMBIGUITY" = "1" ]; then
[ "$PLAN_AMBIGUOUS" = "1" ] && printf '%s\n' 'PWF_PLAN_AMBIGUOUS_V1'
exit 0
fi
[ "$PLAN_AMBIGUOUS" = "1" ] && exit 0
if [ -n "${PLAN_ID:-}" ]; then
resolve_from_env && exit 0
exit 0
@@ -404,6 +404,7 @@ function Resolve-PlanFile {
$resolver = Join-Path $PSScriptRoot "resolve-plan-dir.ps1"
if (-not (Test-Path -LiteralPath $resolver -PathType Leaf)) { return $null }
$resolvedDir = @(& $resolver | Where-Object { $_ }) | Select-Object -First 1
if (-not $resolvedDir -and ((& $resolver -CheckAmbiguity) -eq "PWF_PLAN_AMBIGUOUS_V1")) { return $null }
if ($resolvedDir) {
$planFile = Join-Path $resolvedDir "task_plan.md"
return (Resolve-ContainedPlanFile -Candidate $planFile -ExpectedDirectory $resolvedDir)
@@ -45,6 +45,10 @@ resolve_plan_file() {
plan_dir=""
if [ -f "${RESOLVER}" ]; then
plan_dir="$(sh "${RESOLVER}" 2>/dev/null)"
if [ -z "$plan_dir" ] && [ "$(sh "${RESOLVER}" --check-ambiguity 2>/dev/null)" = "PWF_PLAN_AMBIGUOUS_V1" ]; then
printf "[plan-attest] Multiple plans are available. Set PLAN_ID=<slug>; nothing was attested.\n" >&2
return 1
fi
fi
if [ -n "${plan_dir}" ] && [ -f "${plan_dir}/task_plan.md" ]; then
printf "%s\n" "${plan_dir}/task_plan.md"
@@ -35,6 +35,7 @@ if ($PlanFile -ne "") {
try {
$resolvedDir = (& $resolver 2>$null | Select-Object -First 1)
if ($null -eq $resolvedDir) { $resolvedDir = "" }
if (-not $resolvedDir -and ((& $resolver -CheckAmbiguity) -eq "PWF_PLAN_AMBIGUOUS_V1")) { exit 0 }
} catch {
$resolvedDir = ""
}
@@ -56,6 +56,9 @@ else
RESOLVED_DIR=""
if [ -f "${RESOLVER}" ]; then
RESOLVED_DIR="$(sh "${RESOLVER}" 2>/dev/null)"
if [ -z "$RESOLVED_DIR" ] && [ "$(sh "${RESOLVER}" --check-ambiguity 2>/dev/null)" = "PWF_PLAN_AMBIGUOUS_V1" ]; then
exit 0
fi
fi
if [ -n "${RESOLVED_DIR}" ] && [ -f "${RESOLVED_DIR}/task_plan.md" ]; then
PLAN_FILE="${RESOLVED_DIR}/task_plan.md"
@@ -13,7 +13,8 @@
# junction/symlink escape; slug validation alone blocks textual traversal.
param(
[string]$PlanRoot = (Join-Path (Get-Location) ".planning")
[string]$PlanRoot = (Join-Path (Get-Location) ".planning"),
[switch]$CheckAmbiguity
)
$projectRoot = (Get-Location).Path
@@ -137,6 +138,30 @@ $activeFile = Join-Path $PlanRoot ".active_plan"
# at rc=0. Emptiness is the fail-closed signal on this channel, matching
# resolve-plan-dir.sh and the PWF_PLAN_ROOT pin. An empty $env:PLAN_ID is
# falsy here and still means "unset".
# The optional probe distinguishes ambiguity from a legacy-root fallback.
# Multiple named plans require PLAN_ID even without a sessions directory.
$planCount = 0
if (-not $env:PLAN_ID) {
if ((Test-Path -LiteralPath (Join-Path $PlanRoot "sessions") -PathType Container) -and
(Test-Path -LiteralPath (Join-Path $projectRoot "task_plan.md") -PathType Leaf)) {
$planCount = 1
}
if (Test-Path -LiteralPath $PlanRoot -PathType Container) {
foreach ($entry in (Get-ChildItem -LiteralPath $PlanRoot -Directory -ErrorAction SilentlyContinue)) {
if ((Test-ValidSlug $entry.Name) -and
(Test-Path -LiteralPath (Join-Path $entry.FullName "task_plan.md") -PathType Leaf)) {
$planCount++
if ($planCount -gt 1) { break }
}
}
}
}
if ($CheckAmbiguity) {
if ($planCount -gt 1) { Write-Output "PWF_PLAN_AMBIGUOUS_V1" }
exit 0
}
if ($planCount -gt 1) { exit 0 }
if ($env:PLAN_ID) {
if (Test-ValidSlug $env:PLAN_ID) {
$candidate = Join-Path $PlanRoot $env:PLAN_ID
@@ -15,6 +15,13 @@
set -u
# Optional probe distinguishes ambiguity from the empty legacy-root result.
# Both modes keep stdout data-only and always exit zero.
CHECK_AMBIGUITY=0
if [ "${1:-}" = "--check-ambiguity" ]; then
CHECK_AMBIGUITY=1
shift
fi
PLAN_ROOT="${1:-${PWD}/.planning}"
# --- PWF_PLAN_ROOT: absolute plan-root binding (issue #212). ---
@@ -321,6 +328,29 @@ resolve_latest_dir() {
# the fail-closed signal on this channel, exactly as the PWF_PLAN_ROOT guard
# above already does it; a non-zero status would kill callers running under
# set -e for a condition that is not an internal error.
# A shared pointer or mtime is not a per-session binding (issue #240).
# Count conservatively, just like injection: slug-valid live plan files.
# The legacy root joins the count only when session isolation is armed.
PLAN_AMBIGUOUS=0
if [ -z "${PLAN_ID:-}" ]; then
PLAN_COUNT=0
if [ -d "${PLAN_ROOT}/sessions" ] && [ -f "${PWF_ROOT_PIN:-.}/task_plan.md" ]; then
PLAN_COUNT=1
fi
for plan_candidate in "${PLAN_ROOT}"/*/task_plan.md; do
[ -f "$plan_candidate" ] || continue
plan_candidate_dir="${plan_candidate%/task_plan.md}"
slug_is_valid "${plan_candidate_dir##*/}" || continue
PLAN_COUNT=$((PLAN_COUNT + 1))
if [ "$PLAN_COUNT" -gt 1 ]; then PLAN_AMBIGUOUS=1; break; fi
done
fi
if [ "$CHECK_AMBIGUITY" = "1" ]; then
[ "$PLAN_AMBIGUOUS" = "1" ] && printf '%s\n' 'PWF_PLAN_AMBIGUOUS_V1'
exit 0
fi
[ "$PLAN_AMBIGUOUS" = "1" ] && exit 0
if [ -n "${PLAN_ID:-}" ]; then
resolve_from_env && exit 0
exit 0
@@ -59,12 +59,9 @@ Windows runs the shell chain through Git Bash and nothing else:
one turn-marker slot and one progress-guard slot per plan.
Known, accepted differences from the shell reference:
* Two plan directories with the same whole-second mtime, no .active_plan
and no PLAN_ID: the reference picks the first in the shell's glob order
(locale collation), this twin the first in code-point order. The same
collation difference can change which three of four or more nested
projects the ambiguity notice names. A machine runs one implementation,
so neither choice flips within a session.
* Shell glob order follows locale collation; this twin uses code-point
order. This can change which three of four or more nested projects the
ambiguity notice names.
* BSD sed (macOS) appends a newline to a progress.md whose last line has
none; GNU sed and this twin do not.
* With PWF_PLAN_ROOT set under Git Bash the shell sees the pin as typed
@@ -837,6 +834,16 @@ class Injector(object):
scope = ""
explicit = bool(plan_prefix)
plan_id = env.get("PLAN_ID", "")
ambiguous = plan_is_ambiguous(
plan_prefix + ".planning", plan_root_pin if plan_root_pin else ".", plan_id
)
if ambiguous and (context == "preflight" or not is_dir(plan_prefix + ".planning/sessions")):
if context == "userprompt":
self.echo(
"[planning-with-files] Multiple plans are available. Set PLAN_ID=<slug> "
"for this session; nothing injected."
)
raise Bail()
if plan_id:
if slug_is_valid(plan_id) and is_dir(plan_prefix + ".planning/" + plan_id):
resolved = plan_prefix + ".planning/" + plan_id
@@ -923,30 +930,14 @@ class Injector(object):
"single-session mode."
)
raise Bail()
if not plan_id:
plan_n = 1 if is_file(plan_prefix + "task_plan.md") else 0
try:
names = sorted(os.listdir(plan_prefix + ".planning"))
except OSError:
names = []
for name in names:
if name.startswith("."):
continue
if not is_file(plan_prefix + ".planning/" + name + "/task_plan.md"):
continue
if not slug_is_valid(name):
continue
plan_n += 1
if plan_n > 1:
break
if plan_n > 1:
if context == "userprompt":
self.echo(
"[planning-with-files] Multiple plans are available while session "
"isolation is armed. Set PLAN_ID=<slug> for this session; nothing "
"injected."
)
raise Bail()
if ambiguous:
if context == "userprompt":
self.echo(
"[planning-with-files] Multiple plans are available while session "
"isolation is armed. Set PLAN_ID=<slug> for this session; nothing "
"injected."
)
raise Bail()
if not explicit:
nested = []
@@ -1326,6 +1317,23 @@ def inject(context, env=None):
# Twin of resolve-plan-dir.sh (the shared resolver the dispatcher uses).
# --------------------------------------------------------------------------
def plan_is_ambiguous(plan_root, project_root, plan_id=""):
"""A project pointer or mtime cannot bind a session to multiple plans."""
if plan_id:
return False
count = 1 if is_dir(plan_root + "/sessions") and is_file(project_root + "/task_plan.md") else 0
try:
names = os.listdir(plan_root)
except OSError:
names = []
for name in names:
if slug_is_valid(name) and is_file(plan_root + "/" + name + "/task_plan.md"):
count += 1
if count > 1:
return True
return False
def resolve_plan_dir(env=None):
"""Return (spelled, filesystem) paths of the plan directory, or ("", "").
@@ -1360,6 +1368,9 @@ def resolve_plan_dir(env=None):
return found(plan_id)
return ("", "")
if plan_is_ambiguous(fs_root, pin if pin else "."):
return ("", "")
active_file = fs_root + "/.active_plan"
if is_file(active_file):
try:
@@ -1441,6 +1452,8 @@ class ClaudeDispatcher(object):
return (spelled, fs)
if self.env.get("PLAN_ID", "") or self.env.get("PWF_PLAN_ROOT", ""):
return ("", "")
if plan_is_ambiguous(".planning", "."):
return ("", "")
if is_file("task_plan.md"):
return (".", ".")
return ("", "")
@@ -254,6 +254,29 @@ is_within_root() {
# The .active_plan pointer, the newest-by-mtime fallback, and the legacy root
# task_plan.md are cwd GUESSES — only guesses are subject to the nested-root
# conflict check below.
# Shared .active_plan and directory mtime cannot identify this session's plan.
# Check before selection or preflight, even when isolation was never armed.
PLAN_AMBIGUOUS=0
if [ -z "${PLAN_ID:-}" ]; then
PLAN_COUNT=0
if [ -d "${PLAN_PREFIX}.planning/sessions" ] && [ -f "${PLAN_PREFIX}task_plan.md" ]; then
PLAN_COUNT=1
fi
for plan_candidate in "${PLAN_PREFIX}".planning/*/task_plan.md; do
[ -f "$plan_candidate" ] || continue
plan_candidate_dir="${plan_candidate%/task_plan.md}"
slug_is_valid "${plan_candidate_dir##*/}" || continue
PLAN_COUNT=$((PLAN_COUNT + 1))
if [ "$PLAN_COUNT" -gt 1 ]; then PLAN_AMBIGUOUS=1; break; fi
done
fi
if [ "$PLAN_AMBIGUOUS" = "1" ] && { [ "$CONTEXT" = "preflight" ] || [ ! -d "${PLAN_PREFIX}.planning/sessions" ]; }; then
if [ "$CONTEXT" = "userprompt" ]; then
echo "[planning-with-files] Multiple plans are available. Set PLAN_ID=<slug> for this session; nothing injected."
fi
exit 0
fi
RESOLVED=""
SCOPE=""
EXPLICIT=0
@@ -442,27 +465,13 @@ PY
exit 0
fi
# An attachment admits a session but does not select one of several plans.
# When isolation is armed, require PLAN_ID if more than one live same-root
# candidate exists. PWF_PLAN_ROOT selects the project root, not a plan
# within that root.
if [ -z "${PLAN_ID:-}" ]; then
SESSION_PLAN_N=0
[ -f "${PLAN_PREFIX}task_plan.md" ] && SESSION_PLAN_N=1
for candidate in "${PLAN_PREFIX}".planning/*/task_plan.md; do
[ -f "$candidate" ] || continue
candidate_dir="${candidate%/task_plan.md}"
candidate_slug="${candidate_dir##*/}"
slug_is_valid "$candidate_slug" || continue
SESSION_PLAN_N=$((SESSION_PLAN_N + 1))
[ "$SESSION_PLAN_N" -gt 1 ] && break
done
if [ "$SESSION_PLAN_N" -gt 1 ]; then
if [ "$CONTEXT" = "userprompt" ]; then
echo "[planning-with-files] Multiple plans are available while session isolation is armed. Set PLAN_ID=<slug> for this session; nothing injected."
fi
exit 0
# Attachment admits a session but does not select its plan. Preserve the
# attachment-first notice above for sessions that never opted in.
if [ "$PLAN_AMBIGUOUS" = "1" ]; then
if [ "$CONTEXT" = "userprompt" ]; then
echo "[planning-with-files] Multiple plans are available while session isolation is armed. Set PLAN_ID=<slug> for this session; nothing injected."
fi
exit 0
fi
fi
@@ -15,6 +15,13 @@
set -u
# Optional probe distinguishes ambiguity from the empty legacy-root result.
# Both modes keep stdout data-only and always exit zero.
CHECK_AMBIGUITY=0
if [ "${1:-}" = "--check-ambiguity" ]; then
CHECK_AMBIGUITY=1
shift
fi
PLAN_ROOT="${1:-${PWD}/.planning}"
# --- PWF_PLAN_ROOT: absolute plan-root binding (issue #212). ---
@@ -321,6 +328,29 @@ resolve_latest_dir() {
# the fail-closed signal on this channel, exactly as the PWF_PLAN_ROOT guard
# above already does it; a non-zero status would kill callers running under
# set -e for a condition that is not an internal error.
# A shared pointer or mtime is not a per-session binding (issue #240).
# Count conservatively, just like injection: slug-valid live plan files.
# The legacy root joins the count only when session isolation is armed.
PLAN_AMBIGUOUS=0
if [ -z "${PLAN_ID:-}" ]; then
PLAN_COUNT=0
if [ -d "${PLAN_ROOT}/sessions" ] && [ -f "${PWF_ROOT_PIN:-.}/task_plan.md" ]; then
PLAN_COUNT=1
fi
for plan_candidate in "${PLAN_ROOT}"/*/task_plan.md; do
[ -f "$plan_candidate" ] || continue
plan_candidate_dir="${plan_candidate%/task_plan.md}"
slug_is_valid "${plan_candidate_dir##*/}" || continue
PLAN_COUNT=$((PLAN_COUNT + 1))
if [ "$PLAN_COUNT" -gt 1 ]; then PLAN_AMBIGUOUS=1; break; fi
done
fi
if [ "$CHECK_AMBIGUITY" = "1" ]; then
[ "$PLAN_AMBIGUOUS" = "1" ] && printf '%s\n' 'PWF_PLAN_AMBIGUOUS_V1'
exit 0
fi
[ "$PLAN_AMBIGUOUS" = "1" ] && exit 0
if [ -n "${PLAN_ID:-}" ]; then
resolve_from_env && exit 0
exit 0
@@ -59,12 +59,9 @@ Windows runs the shell chain through Git Bash and nothing else:
one turn-marker slot and one progress-guard slot per plan.
Known, accepted differences from the shell reference:
* Two plan directories with the same whole-second mtime, no .active_plan
and no PLAN_ID: the reference picks the first in the shell's glob order
(locale collation), this twin the first in code-point order. The same
collation difference can change which three of four or more nested
projects the ambiguity notice names. A machine runs one implementation,
so neither choice flips within a session.
* Shell glob order follows locale collation; this twin uses code-point
order. This can change which three of four or more nested projects the
ambiguity notice names.
* BSD sed (macOS) appends a newline to a progress.md whose last line has
none; GNU sed and this twin do not.
* With PWF_PLAN_ROOT set under Git Bash the shell sees the pin as typed
@@ -837,6 +834,16 @@ class Injector(object):
scope = ""
explicit = bool(plan_prefix)
plan_id = env.get("PLAN_ID", "")
ambiguous = plan_is_ambiguous(
plan_prefix + ".planning", plan_root_pin if plan_root_pin else ".", plan_id
)
if ambiguous and (context == "preflight" or not is_dir(plan_prefix + ".planning/sessions")):
if context == "userprompt":
self.echo(
"[planning-with-files] Multiple plans are available. Set PLAN_ID=<slug> "
"for this session; nothing injected."
)
raise Bail()
if plan_id:
if slug_is_valid(plan_id) and is_dir(plan_prefix + ".planning/" + plan_id):
resolved = plan_prefix + ".planning/" + plan_id
@@ -923,30 +930,14 @@ class Injector(object):
"single-session mode."
)
raise Bail()
if not plan_id:
plan_n = 1 if is_file(plan_prefix + "task_plan.md") else 0
try:
names = sorted(os.listdir(plan_prefix + ".planning"))
except OSError:
names = []
for name in names:
if name.startswith("."):
continue
if not is_file(plan_prefix + ".planning/" + name + "/task_plan.md"):
continue
if not slug_is_valid(name):
continue
plan_n += 1
if plan_n > 1:
break
if plan_n > 1:
if context == "userprompt":
self.echo(
"[planning-with-files] Multiple plans are available while session "
"isolation is armed. Set PLAN_ID=<slug> for this session; nothing "
"injected."
)
raise Bail()
if ambiguous:
if context == "userprompt":
self.echo(
"[planning-with-files] Multiple plans are available while session "
"isolation is armed. Set PLAN_ID=<slug> for this session; nothing "
"injected."
)
raise Bail()
if not explicit:
nested = []
@@ -1326,6 +1317,23 @@ def inject(context, env=None):
# Twin of resolve-plan-dir.sh (the shared resolver the dispatcher uses).
# --------------------------------------------------------------------------
def plan_is_ambiguous(plan_root, project_root, plan_id=""):
"""A project pointer or mtime cannot bind a session to multiple plans."""
if plan_id:
return False
count = 1 if is_dir(plan_root + "/sessions") and is_file(project_root + "/task_plan.md") else 0
try:
names = os.listdir(plan_root)
except OSError:
names = []
for name in names:
if slug_is_valid(name) and is_file(plan_root + "/" + name + "/task_plan.md"):
count += 1
if count > 1:
return True
return False
def resolve_plan_dir(env=None):
"""Return (spelled, filesystem) paths of the plan directory, or ("", "").
@@ -1360,6 +1368,9 @@ def resolve_plan_dir(env=None):
return found(plan_id)
return ("", "")
if plan_is_ambiguous(fs_root, pin if pin else "."):
return ("", "")
active_file = fs_root + "/.active_plan"
if is_file(active_file):
try:
@@ -1441,6 +1452,8 @@ class ClaudeDispatcher(object):
return (spelled, fs)
if self.env.get("PLAN_ID", "") or self.env.get("PWF_PLAN_ROOT", ""):
return ("", "")
if plan_is_ambiguous(".planning", "."):
return ("", "")
if is_file("task_plan.md"):
return (".", ".")
return ("", "")
@@ -254,6 +254,29 @@ is_within_root() {
# The .active_plan pointer, the newest-by-mtime fallback, and the legacy root
# task_plan.md are cwd GUESSES — only guesses are subject to the nested-root
# conflict check below.
# Shared .active_plan and directory mtime cannot identify this session's plan.
# Check before selection or preflight, even when isolation was never armed.
PLAN_AMBIGUOUS=0
if [ -z "${PLAN_ID:-}" ]; then
PLAN_COUNT=0
if [ -d "${PLAN_PREFIX}.planning/sessions" ] && [ -f "${PLAN_PREFIX}task_plan.md" ]; then
PLAN_COUNT=1
fi
for plan_candidate in "${PLAN_PREFIX}".planning/*/task_plan.md; do
[ -f "$plan_candidate" ] || continue
plan_candidate_dir="${plan_candidate%/task_plan.md}"
slug_is_valid "${plan_candidate_dir##*/}" || continue
PLAN_COUNT=$((PLAN_COUNT + 1))
if [ "$PLAN_COUNT" -gt 1 ]; then PLAN_AMBIGUOUS=1; break; fi
done
fi
if [ "$PLAN_AMBIGUOUS" = "1" ] && { [ "$CONTEXT" = "preflight" ] || [ ! -d "${PLAN_PREFIX}.planning/sessions" ]; }; then
if [ "$CONTEXT" = "userprompt" ]; then
echo "[planning-with-files] Multiple plans are available. Set PLAN_ID=<slug> for this session; nothing injected."
fi
exit 0
fi
RESOLVED=""
SCOPE=""
EXPLICIT=0
@@ -442,27 +465,13 @@ PY
exit 0
fi
# An attachment admits a session but does not select one of several plans.
# When isolation is armed, require PLAN_ID if more than one live same-root
# candidate exists. PWF_PLAN_ROOT selects the project root, not a plan
# within that root.
if [ -z "${PLAN_ID:-}" ]; then
SESSION_PLAN_N=0
[ -f "${PLAN_PREFIX}task_plan.md" ] && SESSION_PLAN_N=1
for candidate in "${PLAN_PREFIX}".planning/*/task_plan.md; do
[ -f "$candidate" ] || continue
candidate_dir="${candidate%/task_plan.md}"
candidate_slug="${candidate_dir##*/}"
slug_is_valid "$candidate_slug" || continue
SESSION_PLAN_N=$((SESSION_PLAN_N + 1))
[ "$SESSION_PLAN_N" -gt 1 ] && break
done
if [ "$SESSION_PLAN_N" -gt 1 ]; then
if [ "$CONTEXT" = "userprompt" ]; then
echo "[planning-with-files] Multiple plans are available while session isolation is armed. Set PLAN_ID=<slug> for this session; nothing injected."
fi
exit 0
# Attachment admits a session but does not select its plan. Preserve the
# attachment-first notice above for sessions that never opted in.
if [ "$PLAN_AMBIGUOUS" = "1" ]; then
if [ "$CONTEXT" = "userprompt" ]; then
echo "[planning-with-files] Multiple plans are available while session isolation is armed. Set PLAN_ID=<slug> for this session; nothing injected."
fi
exit 0
fi
fi
@@ -15,6 +15,13 @@
set -u
# Optional probe distinguishes ambiguity from the empty legacy-root result.
# Both modes keep stdout data-only and always exit zero.
CHECK_AMBIGUITY=0
if [ "${1:-}" = "--check-ambiguity" ]; then
CHECK_AMBIGUITY=1
shift
fi
PLAN_ROOT="${1:-${PWD}/.planning}"
# --- PWF_PLAN_ROOT: absolute plan-root binding (issue #212). ---
@@ -321,6 +328,29 @@ resolve_latest_dir() {
# the fail-closed signal on this channel, exactly as the PWF_PLAN_ROOT guard
# above already does it; a non-zero status would kill callers running under
# set -e for a condition that is not an internal error.
# A shared pointer or mtime is not a per-session binding (issue #240).
# Count conservatively, just like injection: slug-valid live plan files.
# The legacy root joins the count only when session isolation is armed.
PLAN_AMBIGUOUS=0
if [ -z "${PLAN_ID:-}" ]; then
PLAN_COUNT=0
if [ -d "${PLAN_ROOT}/sessions" ] && [ -f "${PWF_ROOT_PIN:-.}/task_plan.md" ]; then
PLAN_COUNT=1
fi
for plan_candidate in "${PLAN_ROOT}"/*/task_plan.md; do
[ -f "$plan_candidate" ] || continue
plan_candidate_dir="${plan_candidate%/task_plan.md}"
slug_is_valid "${plan_candidate_dir##*/}" || continue
PLAN_COUNT=$((PLAN_COUNT + 1))
if [ "$PLAN_COUNT" -gt 1 ]; then PLAN_AMBIGUOUS=1; break; fi
done
fi
if [ "$CHECK_AMBIGUITY" = "1" ]; then
[ "$PLAN_AMBIGUOUS" = "1" ] && printf '%s\n' 'PWF_PLAN_AMBIGUOUS_V1'
exit 0
fi
[ "$PLAN_AMBIGUOUS" = "1" ] && exit 0
if [ -n "${PLAN_ID:-}" ]; then
resolve_from_env && exit 0
exit 0
@@ -404,6 +404,7 @@ function Resolve-PlanFile {
$resolver = Join-Path $PSScriptRoot "resolve-plan-dir.ps1"
if (-not (Test-Path -LiteralPath $resolver -PathType Leaf)) { return $null }
$resolvedDir = @(& $resolver | Where-Object { $_ }) | Select-Object -First 1
if (-not $resolvedDir -and ((& $resolver -CheckAmbiguity) -eq "PWF_PLAN_AMBIGUOUS_V1")) { return $null }
if ($resolvedDir) {
$planFile = Join-Path $resolvedDir "task_plan.md"
return (Resolve-ContainedPlanFile -Candidate $planFile -ExpectedDirectory $resolvedDir)
@@ -45,6 +45,10 @@ resolve_plan_file() {
plan_dir=""
if [ -f "${RESOLVER}" ]; then
plan_dir="$(sh "${RESOLVER}" 2>/dev/null)"
if [ -z "$plan_dir" ] && [ "$(sh "${RESOLVER}" --check-ambiguity 2>/dev/null)" = "PWF_PLAN_AMBIGUOUS_V1" ]; then
printf "[plan-attest] Multiple plans are available. Set PLAN_ID=<slug>; nothing was attested.\n" >&2
return 1
fi
fi
if [ -n "${plan_dir}" ] && [ -f "${plan_dir}/task_plan.md" ]; then
printf "%s\n" "${plan_dir}/task_plan.md"
@@ -35,6 +35,7 @@ if ($PlanFile -ne "") {
try {
$resolvedDir = (& $resolver 2>$null | Select-Object -First 1)
if ($null -eq $resolvedDir) { $resolvedDir = "" }
if (-not $resolvedDir -and ((& $resolver -CheckAmbiguity) -eq "PWF_PLAN_AMBIGUOUS_V1")) { exit 0 }
} catch {
$resolvedDir = ""
}
@@ -56,6 +56,9 @@ else
RESOLVED_DIR=""
if [ -f "${RESOLVER}" ]; then
RESOLVED_DIR="$(sh "${RESOLVER}" 2>/dev/null)"
if [ -z "$RESOLVED_DIR" ] && [ "$(sh "${RESOLVER}" --check-ambiguity 2>/dev/null)" = "PWF_PLAN_AMBIGUOUS_V1" ]; then
exit 0
fi
fi
if [ -n "${RESOLVED_DIR}" ] && [ -f "${RESOLVED_DIR}/task_plan.md" ]; then
PLAN_FILE="${RESOLVED_DIR}/task_plan.md"
@@ -59,12 +59,9 @@ Windows runs the shell chain through Git Bash and nothing else:
one turn-marker slot and one progress-guard slot per plan.
Known, accepted differences from the shell reference:
* Two plan directories with the same whole-second mtime, no .active_plan
and no PLAN_ID: the reference picks the first in the shell's glob order
(locale collation), this twin the first in code-point order. The same
collation difference can change which three of four or more nested
projects the ambiguity notice names. A machine runs one implementation,
so neither choice flips within a session.
* Shell glob order follows locale collation; this twin uses code-point
order. This can change which three of four or more nested projects the
ambiguity notice names.
* BSD sed (macOS) appends a newline to a progress.md whose last line has
none; GNU sed and this twin do not.
* With PWF_PLAN_ROOT set under Git Bash the shell sees the pin as typed
@@ -837,6 +834,16 @@ class Injector(object):
scope = ""
explicit = bool(plan_prefix)
plan_id = env.get("PLAN_ID", "")
ambiguous = plan_is_ambiguous(
plan_prefix + ".planning", plan_root_pin if plan_root_pin else ".", plan_id
)
if ambiguous and (context == "preflight" or not is_dir(plan_prefix + ".planning/sessions")):
if context == "userprompt":
self.echo(
"[planning-with-files] Multiple plans are available. Set PLAN_ID=<slug> "
"for this session; nothing injected."
)
raise Bail()
if plan_id:
if slug_is_valid(plan_id) and is_dir(plan_prefix + ".planning/" + plan_id):
resolved = plan_prefix + ".planning/" + plan_id
@@ -923,30 +930,14 @@ class Injector(object):
"single-session mode."
)
raise Bail()
if not plan_id:
plan_n = 1 if is_file(plan_prefix + "task_plan.md") else 0
try:
names = sorted(os.listdir(plan_prefix + ".planning"))
except OSError:
names = []
for name in names:
if name.startswith("."):
continue
if not is_file(plan_prefix + ".planning/" + name + "/task_plan.md"):
continue
if not slug_is_valid(name):
continue
plan_n += 1
if plan_n > 1:
break
if plan_n > 1:
if context == "userprompt":
self.echo(
"[planning-with-files] Multiple plans are available while session "
"isolation is armed. Set PLAN_ID=<slug> for this session; nothing "
"injected."
)
raise Bail()
if ambiguous:
if context == "userprompt":
self.echo(
"[planning-with-files] Multiple plans are available while session "
"isolation is armed. Set PLAN_ID=<slug> for this session; nothing "
"injected."
)
raise Bail()
if not explicit:
nested = []
@@ -1326,6 +1317,23 @@ def inject(context, env=None):
# Twin of resolve-plan-dir.sh (the shared resolver the dispatcher uses).
# --------------------------------------------------------------------------
def plan_is_ambiguous(plan_root, project_root, plan_id=""):
"""A project pointer or mtime cannot bind a session to multiple plans."""
if plan_id:
return False
count = 1 if is_dir(plan_root + "/sessions") and is_file(project_root + "/task_plan.md") else 0
try:
names = os.listdir(plan_root)
except OSError:
names = []
for name in names:
if slug_is_valid(name) and is_file(plan_root + "/" + name + "/task_plan.md"):
count += 1
if count > 1:
return True
return False
def resolve_plan_dir(env=None):
"""Return (spelled, filesystem) paths of the plan directory, or ("", "").
@@ -1360,6 +1368,9 @@ def resolve_plan_dir(env=None):
return found(plan_id)
return ("", "")
if plan_is_ambiguous(fs_root, pin if pin else "."):
return ("", "")
active_file = fs_root + "/.active_plan"
if is_file(active_file):
try:
@@ -1441,6 +1452,8 @@ class ClaudeDispatcher(object):
return (spelled, fs)
if self.env.get("PLAN_ID", "") or self.env.get("PWF_PLAN_ROOT", ""):
return ("", "")
if plan_is_ambiguous(".planning", "."):
return ("", "")
if is_file("task_plan.md"):
return (".", ".")
return ("", "")
@@ -254,6 +254,29 @@ is_within_root() {
# The .active_plan pointer, the newest-by-mtime fallback, and the legacy root
# task_plan.md are cwd GUESSES — only guesses are subject to the nested-root
# conflict check below.
# Shared .active_plan and directory mtime cannot identify this session's plan.
# Check before selection or preflight, even when isolation was never armed.
PLAN_AMBIGUOUS=0
if [ -z "${PLAN_ID:-}" ]; then
PLAN_COUNT=0
if [ -d "${PLAN_PREFIX}.planning/sessions" ] && [ -f "${PLAN_PREFIX}task_plan.md" ]; then
PLAN_COUNT=1
fi
for plan_candidate in "${PLAN_PREFIX}".planning/*/task_plan.md; do
[ -f "$plan_candidate" ] || continue
plan_candidate_dir="${plan_candidate%/task_plan.md}"
slug_is_valid "${plan_candidate_dir##*/}" || continue
PLAN_COUNT=$((PLAN_COUNT + 1))
if [ "$PLAN_COUNT" -gt 1 ]; then PLAN_AMBIGUOUS=1; break; fi
done
fi
if [ "$PLAN_AMBIGUOUS" = "1" ] && { [ "$CONTEXT" = "preflight" ] || [ ! -d "${PLAN_PREFIX}.planning/sessions" ]; }; then
if [ "$CONTEXT" = "userprompt" ]; then
echo "[planning-with-files] Multiple plans are available. Set PLAN_ID=<slug> for this session; nothing injected."
fi
exit 0
fi
RESOLVED=""
SCOPE=""
EXPLICIT=0
@@ -442,27 +465,13 @@ PY
exit 0
fi
# An attachment admits a session but does not select one of several plans.
# When isolation is armed, require PLAN_ID if more than one live same-root
# candidate exists. PWF_PLAN_ROOT selects the project root, not a plan
# within that root.
if [ -z "${PLAN_ID:-}" ]; then
SESSION_PLAN_N=0
[ -f "${PLAN_PREFIX}task_plan.md" ] && SESSION_PLAN_N=1
for candidate in "${PLAN_PREFIX}".planning/*/task_plan.md; do
[ -f "$candidate" ] || continue
candidate_dir="${candidate%/task_plan.md}"
candidate_slug="${candidate_dir##*/}"
slug_is_valid "$candidate_slug" || continue
SESSION_PLAN_N=$((SESSION_PLAN_N + 1))
[ "$SESSION_PLAN_N" -gt 1 ] && break
done
if [ "$SESSION_PLAN_N" -gt 1 ]; then
if [ "$CONTEXT" = "userprompt" ]; then
echo "[planning-with-files] Multiple plans are available while session isolation is armed. Set PLAN_ID=<slug> for this session; nothing injected."
fi
exit 0
# Attachment admits a session but does not select its plan. Preserve the
# attachment-first notice above for sessions that never opted in.
if [ "$PLAN_AMBIGUOUS" = "1" ]; then
if [ "$CONTEXT" = "userprompt" ]; then
echo "[planning-with-files] Multiple plans are available while session isolation is armed. Set PLAN_ID=<slug> for this session; nothing injected."
fi
exit 0
fi
fi
@@ -13,7 +13,8 @@
# junction/symlink escape; slug validation alone blocks textual traversal.
param(
[string]$PlanRoot = (Join-Path (Get-Location) ".planning")
[string]$PlanRoot = (Join-Path (Get-Location) ".planning"),
[switch]$CheckAmbiguity
)
$projectRoot = (Get-Location).Path
@@ -137,6 +138,30 @@ $activeFile = Join-Path $PlanRoot ".active_plan"
# at rc=0. Emptiness is the fail-closed signal on this channel, matching
# resolve-plan-dir.sh and the PWF_PLAN_ROOT pin. An empty $env:PLAN_ID is
# falsy here and still means "unset".
# The optional probe distinguishes ambiguity from a legacy-root fallback.
# Multiple named plans require PLAN_ID even without a sessions directory.
$planCount = 0
if (-not $env:PLAN_ID) {
if ((Test-Path -LiteralPath (Join-Path $PlanRoot "sessions") -PathType Container) -and
(Test-Path -LiteralPath (Join-Path $projectRoot "task_plan.md") -PathType Leaf)) {
$planCount = 1
}
if (Test-Path -LiteralPath $PlanRoot -PathType Container) {
foreach ($entry in (Get-ChildItem -LiteralPath $PlanRoot -Directory -ErrorAction SilentlyContinue)) {
if ((Test-ValidSlug $entry.Name) -and
(Test-Path -LiteralPath (Join-Path $entry.FullName "task_plan.md") -PathType Leaf)) {
$planCount++
if ($planCount -gt 1) { break }
}
}
}
}
if ($CheckAmbiguity) {
if ($planCount -gt 1) { Write-Output "PWF_PLAN_AMBIGUOUS_V1" }
exit 0
}
if ($planCount -gt 1) { exit 0 }
if ($env:PLAN_ID) {
if (Test-ValidSlug $env:PLAN_ID) {
$candidate = Join-Path $PlanRoot $env:PLAN_ID
@@ -15,6 +15,13 @@
set -u
# Optional probe distinguishes ambiguity from the empty legacy-root result.
# Both modes keep stdout data-only and always exit zero.
CHECK_AMBIGUITY=0
if [ "${1:-}" = "--check-ambiguity" ]; then
CHECK_AMBIGUITY=1
shift
fi
PLAN_ROOT="${1:-${PWD}/.planning}"
# --- PWF_PLAN_ROOT: absolute plan-root binding (issue #212). ---
@@ -321,6 +328,29 @@ resolve_latest_dir() {
# the fail-closed signal on this channel, exactly as the PWF_PLAN_ROOT guard
# above already does it; a non-zero status would kill callers running under
# set -e for a condition that is not an internal error.
# A shared pointer or mtime is not a per-session binding (issue #240).
# Count conservatively, just like injection: slug-valid live plan files.
# The legacy root joins the count only when session isolation is armed.
PLAN_AMBIGUOUS=0
if [ -z "${PLAN_ID:-}" ]; then
PLAN_COUNT=0
if [ -d "${PLAN_ROOT}/sessions" ] && [ -f "${PWF_ROOT_PIN:-.}/task_plan.md" ]; then
PLAN_COUNT=1
fi
for plan_candidate in "${PLAN_ROOT}"/*/task_plan.md; do
[ -f "$plan_candidate" ] || continue
plan_candidate_dir="${plan_candidate%/task_plan.md}"
slug_is_valid "${plan_candidate_dir##*/}" || continue
PLAN_COUNT=$((PLAN_COUNT + 1))
if [ "$PLAN_COUNT" -gt 1 ]; then PLAN_AMBIGUOUS=1; break; fi
done
fi
if [ "$CHECK_AMBIGUITY" = "1" ]; then
[ "$PLAN_AMBIGUOUS" = "1" ] && printf '%s\n' 'PWF_PLAN_AMBIGUOUS_V1'
exit 0
fi
[ "$PLAN_AMBIGUOUS" = "1" ] && exit 0
if [ -n "${PLAN_ID:-}" ]; then
resolve_from_env && exit 0
exit 0
+3
View File
@@ -164,6 +164,9 @@ active_plan_dir() {
_resolved=""
if [ -f "$RESOLVE_PLAN_DIR" ]; then
_resolved=$(sh "$RESOLVE_PLAN_DIR" 2>/dev/null) || _resolved=""
if [ -z "$_resolved" ] && [ "$(sh "$RESOLVE_PLAN_DIR" --check-ambiguity 2>/dev/null)" = "PWF_PLAN_AMBIGUOUS_V1" ]; then
return 0
fi
fi
if [ -n "$_resolved" ] && [ -f "${_resolved}/task_plan.md" ]; then
printf '%s\n' "$_resolved"
+1
View File
@@ -404,6 +404,7 @@ function Resolve-PlanFile {
$resolver = Join-Path $PSScriptRoot "resolve-plan-dir.ps1"
if (-not (Test-Path -LiteralPath $resolver -PathType Leaf)) { return $null }
$resolvedDir = @(& $resolver | Where-Object { $_ }) | Select-Object -First 1
if (-not $resolvedDir -and ((& $resolver -CheckAmbiguity) -eq "PWF_PLAN_AMBIGUOUS_V1")) { return $null }
if ($resolvedDir) {
$planFile = Join-Path $resolvedDir "task_plan.md"
return (Resolve-ContainedPlanFile -Candidate $planFile -ExpectedDirectory $resolvedDir)
+4
View File
@@ -45,6 +45,10 @@ resolve_plan_file() {
plan_dir=""
if [ -f "${RESOLVER}" ]; then
plan_dir="$(sh "${RESOLVER}" 2>/dev/null)"
if [ -z "$plan_dir" ] && [ "$(sh "${RESOLVER}" --check-ambiguity 2>/dev/null)" = "PWF_PLAN_AMBIGUOUS_V1" ]; then
printf "[plan-attest] Multiple plans are available. Set PLAN_ID=<slug>; nothing was attested.\n" >&2
return 1
fi
fi
if [ -n "${plan_dir}" ] && [ -f "${plan_dir}/task_plan.md" ]; then
printf "%s\n" "${plan_dir}/task_plan.md"
+1
View File
@@ -35,6 +35,7 @@ if ($PlanFile -ne "") {
try {
$resolvedDir = (& $resolver 2>$null | Select-Object -First 1)
if ($null -eq $resolvedDir) { $resolvedDir = "" }
if (-not $resolvedDir -and ((& $resolver -CheckAmbiguity) -eq "PWF_PLAN_AMBIGUOUS_V1")) { exit 0 }
} catch {
$resolvedDir = ""
}
+3
View File
@@ -56,6 +56,9 @@ else
RESOLVED_DIR=""
if [ -f "${RESOLVER}" ]; then
RESOLVED_DIR="$(sh "${RESOLVER}" 2>/dev/null)"
if [ -z "$RESOLVED_DIR" ] && [ "$(sh "${RESOLVER}" --check-ambiguity 2>/dev/null)" = "PWF_PLAN_AMBIGUOUS_V1" ]; then
exit 0
fi
fi
if [ -n "${RESOLVED_DIR}" ] && [ -f "${RESOLVED_DIR}/task_plan.md" ]; then
PLAN_FILE="${RESOLVED_DIR}/task_plan.md"
+43 -30
View File
@@ -59,12 +59,9 @@ Windows runs the shell chain through Git Bash and nothing else:
one turn-marker slot and one progress-guard slot per plan.
Known, accepted differences from the shell reference:
* Two plan directories with the same whole-second mtime, no .active_plan
and no PLAN_ID: the reference picks the first in the shell's glob order
(locale collation), this twin the first in code-point order. The same
collation difference can change which three of four or more nested
projects the ambiguity notice names. A machine runs one implementation,
so neither choice flips within a session.
* Shell glob order follows locale collation; this twin uses code-point
order. This can change which three of four or more nested projects the
ambiguity notice names.
* BSD sed (macOS) appends a newline to a progress.md whose last line has
none; GNU sed and this twin do not.
* With PWF_PLAN_ROOT set under Git Bash the shell sees the pin as typed
@@ -837,6 +834,16 @@ class Injector(object):
scope = ""
explicit = bool(plan_prefix)
plan_id = env.get("PLAN_ID", "")
ambiguous = plan_is_ambiguous(
plan_prefix + ".planning", plan_root_pin if plan_root_pin else ".", plan_id
)
if ambiguous and (context == "preflight" or not is_dir(plan_prefix + ".planning/sessions")):
if context == "userprompt":
self.echo(
"[planning-with-files] Multiple plans are available. Set PLAN_ID=<slug> "
"for this session; nothing injected."
)
raise Bail()
if plan_id:
if slug_is_valid(plan_id) and is_dir(plan_prefix + ".planning/" + plan_id):
resolved = plan_prefix + ".planning/" + plan_id
@@ -923,30 +930,14 @@ class Injector(object):
"single-session mode."
)
raise Bail()
if not plan_id:
plan_n = 1 if is_file(plan_prefix + "task_plan.md") else 0
try:
names = sorted(os.listdir(plan_prefix + ".planning"))
except OSError:
names = []
for name in names:
if name.startswith("."):
continue
if not is_file(plan_prefix + ".planning/" + name + "/task_plan.md"):
continue
if not slug_is_valid(name):
continue
plan_n += 1
if plan_n > 1:
break
if plan_n > 1:
if context == "userprompt":
self.echo(
"[planning-with-files] Multiple plans are available while session "
"isolation is armed. Set PLAN_ID=<slug> for this session; nothing "
"injected."
)
raise Bail()
if ambiguous:
if context == "userprompt":
self.echo(
"[planning-with-files] Multiple plans are available while session "
"isolation is armed. Set PLAN_ID=<slug> for this session; nothing "
"injected."
)
raise Bail()
if not explicit:
nested = []
@@ -1326,6 +1317,23 @@ def inject(context, env=None):
# Twin of resolve-plan-dir.sh (the shared resolver the dispatcher uses).
# --------------------------------------------------------------------------
def plan_is_ambiguous(plan_root, project_root, plan_id=""):
"""A project pointer or mtime cannot bind a session to multiple plans."""
if plan_id:
return False
count = 1 if is_dir(plan_root + "/sessions") and is_file(project_root + "/task_plan.md") else 0
try:
names = os.listdir(plan_root)
except OSError:
names = []
for name in names:
if slug_is_valid(name) and is_file(plan_root + "/" + name + "/task_plan.md"):
count += 1
if count > 1:
return True
return False
def resolve_plan_dir(env=None):
"""Return (spelled, filesystem) paths of the plan directory, or ("", "").
@@ -1360,6 +1368,9 @@ def resolve_plan_dir(env=None):
return found(plan_id)
return ("", "")
if plan_is_ambiguous(fs_root, pin if pin else "."):
return ("", "")
active_file = fs_root + "/.active_plan"
if is_file(active_file):
try:
@@ -1441,6 +1452,8 @@ class ClaudeDispatcher(object):
return (spelled, fs)
if self.env.get("PLAN_ID", "") or self.env.get("PWF_PLAN_ROOT", ""):
return ("", "")
if plan_is_ambiguous(".planning", "."):
return ("", "")
if is_file("task_plan.md"):
return (".", ".")
return ("", "")
+29 -20
View File
@@ -254,6 +254,29 @@ is_within_root() {
# The .active_plan pointer, the newest-by-mtime fallback, and the legacy root
# task_plan.md are cwd GUESSES — only guesses are subject to the nested-root
# conflict check below.
# Shared .active_plan and directory mtime cannot identify this session's plan.
# Check before selection or preflight, even when isolation was never armed.
PLAN_AMBIGUOUS=0
if [ -z "${PLAN_ID:-}" ]; then
PLAN_COUNT=0
if [ -d "${PLAN_PREFIX}.planning/sessions" ] && [ -f "${PLAN_PREFIX}task_plan.md" ]; then
PLAN_COUNT=1
fi
for plan_candidate in "${PLAN_PREFIX}".planning/*/task_plan.md; do
[ -f "$plan_candidate" ] || continue
plan_candidate_dir="${plan_candidate%/task_plan.md}"
slug_is_valid "${plan_candidate_dir##*/}" || continue
PLAN_COUNT=$((PLAN_COUNT + 1))
if [ "$PLAN_COUNT" -gt 1 ]; then PLAN_AMBIGUOUS=1; break; fi
done
fi
if [ "$PLAN_AMBIGUOUS" = "1" ] && { [ "$CONTEXT" = "preflight" ] || [ ! -d "${PLAN_PREFIX}.planning/sessions" ]; }; then
if [ "$CONTEXT" = "userprompt" ]; then
echo "[planning-with-files] Multiple plans are available. Set PLAN_ID=<slug> for this session; nothing injected."
fi
exit 0
fi
RESOLVED=""
SCOPE=""
EXPLICIT=0
@@ -442,27 +465,13 @@ PY
exit 0
fi
# An attachment admits a session but does not select one of several plans.
# When isolation is armed, require PLAN_ID if more than one live same-root
# candidate exists. PWF_PLAN_ROOT selects the project root, not a plan
# within that root.
if [ -z "${PLAN_ID:-}" ]; then
SESSION_PLAN_N=0
[ -f "${PLAN_PREFIX}task_plan.md" ] && SESSION_PLAN_N=1
for candidate in "${PLAN_PREFIX}".planning/*/task_plan.md; do
[ -f "$candidate" ] || continue
candidate_dir="${candidate%/task_plan.md}"
candidate_slug="${candidate_dir##*/}"
slug_is_valid "$candidate_slug" || continue
SESSION_PLAN_N=$((SESSION_PLAN_N + 1))
[ "$SESSION_PLAN_N" -gt 1 ] && break
done
if [ "$SESSION_PLAN_N" -gt 1 ]; then
if [ "$CONTEXT" = "userprompt" ]; then
echo "[planning-with-files] Multiple plans are available while session isolation is armed. Set PLAN_ID=<slug> for this session; nothing injected."
fi
exit 0
# Attachment admits a session but does not select its plan. Preserve the
# attachment-first notice above for sessions that never opted in.
if [ "$PLAN_AMBIGUOUS" = "1" ]; then
if [ "$CONTEXT" = "userprompt" ]; then
echo "[planning-with-files] Multiple plans are available while session isolation is armed. Set PLAN_ID=<slug> for this session; nothing injected."
fi
exit 0
fi
fi
+26 -1
View File
@@ -13,7 +13,8 @@
# junction/symlink escape; slug validation alone blocks textual traversal.
param(
[string]$PlanRoot = (Join-Path (Get-Location) ".planning")
[string]$PlanRoot = (Join-Path (Get-Location) ".planning"),
[switch]$CheckAmbiguity
)
$projectRoot = (Get-Location).Path
@@ -137,6 +138,30 @@ $activeFile = Join-Path $PlanRoot ".active_plan"
# at rc=0. Emptiness is the fail-closed signal on this channel, matching
# resolve-plan-dir.sh and the PWF_PLAN_ROOT pin. An empty $env:PLAN_ID is
# falsy here and still means "unset".
# The optional probe distinguishes ambiguity from a legacy-root fallback.
# Multiple named plans require PLAN_ID even without a sessions directory.
$planCount = 0
if (-not $env:PLAN_ID) {
if ((Test-Path -LiteralPath (Join-Path $PlanRoot "sessions") -PathType Container) -and
(Test-Path -LiteralPath (Join-Path $projectRoot "task_plan.md") -PathType Leaf)) {
$planCount = 1
}
if (Test-Path -LiteralPath $PlanRoot -PathType Container) {
foreach ($entry in (Get-ChildItem -LiteralPath $PlanRoot -Directory -ErrorAction SilentlyContinue)) {
if ((Test-ValidSlug $entry.Name) -and
(Test-Path -LiteralPath (Join-Path $entry.FullName "task_plan.md") -PathType Leaf)) {
$planCount++
if ($planCount -gt 1) { break }
}
}
}
}
if ($CheckAmbiguity) {
if ($planCount -gt 1) { Write-Output "PWF_PLAN_AMBIGUOUS_V1" }
exit 0
}
if ($planCount -gt 1) { exit 0 }
if ($env:PLAN_ID) {
if (Test-ValidSlug $env:PLAN_ID) {
$candidate = Join-Path $PlanRoot $env:PLAN_ID
+30
View File
@@ -15,6 +15,13 @@
set -u
# Optional probe distinguishes ambiguity from the empty legacy-root result.
# Both modes keep stdout data-only and always exit zero.
CHECK_AMBIGUITY=0
if [ "${1:-}" = "--check-ambiguity" ]; then
CHECK_AMBIGUITY=1
shift
fi
PLAN_ROOT="${1:-${PWD}/.planning}"
# --- PWF_PLAN_ROOT: absolute plan-root binding (issue #212). ---
@@ -321,6 +328,29 @@ resolve_latest_dir() {
# the fail-closed signal on this channel, exactly as the PWF_PLAN_ROOT guard
# above already does it; a non-zero status would kill callers running under
# set -e for a condition that is not an internal error.
# A shared pointer or mtime is not a per-session binding (issue #240).
# Count conservatively, just like injection: slug-valid live plan files.
# The legacy root joins the count only when session isolation is armed.
PLAN_AMBIGUOUS=0
if [ -z "${PLAN_ID:-}" ]; then
PLAN_COUNT=0
if [ -d "${PLAN_ROOT}/sessions" ] && [ -f "${PWF_ROOT_PIN:-.}/task_plan.md" ]; then
PLAN_COUNT=1
fi
for plan_candidate in "${PLAN_ROOT}"/*/task_plan.md; do
[ -f "$plan_candidate" ] || continue
plan_candidate_dir="${plan_candidate%/task_plan.md}"
slug_is_valid "${plan_candidate_dir##*/}" || continue
PLAN_COUNT=$((PLAN_COUNT + 1))
if [ "$PLAN_COUNT" -gt 1 ]; then PLAN_AMBIGUOUS=1; break; fi
done
fi
if [ "$CHECK_AMBIGUITY" = "1" ]; then
[ "$PLAN_AMBIGUOUS" = "1" ] && printf '%s\n' 'PWF_PLAN_AMBIGUOUS_V1'
exit 0
fi
[ "$PLAN_AMBIGUOUS" = "1" ] && exit 0
if [ -n "${PLAN_ID:-}" ]; then
resolve_from_env && exit 0
exit 0
@@ -404,6 +404,7 @@ function Resolve-PlanFile {
$resolver = Join-Path $PSScriptRoot "resolve-plan-dir.ps1"
if (-not (Test-Path -LiteralPath $resolver -PathType Leaf)) { return $null }
$resolvedDir = @(& $resolver | Where-Object { $_ }) | Select-Object -First 1
if (-not $resolvedDir -and ((& $resolver -CheckAmbiguity) -eq "PWF_PLAN_AMBIGUOUS_V1")) { return $null }
if ($resolvedDir) {
$planFile = Join-Path $resolvedDir "task_plan.md"
return (Resolve-ContainedPlanFile -Candidate $planFile -ExpectedDirectory $resolvedDir)
@@ -45,6 +45,10 @@ resolve_plan_file() {
plan_dir=""
if [ -f "${RESOLVER}" ]; then
plan_dir="$(sh "${RESOLVER}" 2>/dev/null)"
if [ -z "$plan_dir" ] && [ "$(sh "${RESOLVER}" --check-ambiguity 2>/dev/null)" = "PWF_PLAN_AMBIGUOUS_V1" ]; then
printf "[plan-attest] Multiple plans are available. Set PLAN_ID=<slug>; nothing was attested.\n" >&2
return 1
fi
fi
if [ -n "${plan_dir}" ] && [ -f "${plan_dir}/task_plan.md" ]; then
printf "%s\n" "${plan_dir}/task_plan.md"
@@ -59,12 +59,9 @@ Windows runs the shell chain through Git Bash and nothing else:
one turn-marker slot and one progress-guard slot per plan.
Known, accepted differences from the shell reference:
* Two plan directories with the same whole-second mtime, no .active_plan
and no PLAN_ID: the reference picks the first in the shell's glob order
(locale collation), this twin the first in code-point order. The same
collation difference can change which three of four or more nested
projects the ambiguity notice names. A machine runs one implementation,
so neither choice flips within a session.
* Shell glob order follows locale collation; this twin uses code-point
order. This can change which three of four or more nested projects the
ambiguity notice names.
* BSD sed (macOS) appends a newline to a progress.md whose last line has
none; GNU sed and this twin do not.
* With PWF_PLAN_ROOT set under Git Bash the shell sees the pin as typed
@@ -837,6 +834,16 @@ class Injector(object):
scope = ""
explicit = bool(plan_prefix)
plan_id = env.get("PLAN_ID", "")
ambiguous = plan_is_ambiguous(
plan_prefix + ".planning", plan_root_pin if plan_root_pin else ".", plan_id
)
if ambiguous and (context == "preflight" or not is_dir(plan_prefix + ".planning/sessions")):
if context == "userprompt":
self.echo(
"[planning-with-files] Multiple plans are available. Set PLAN_ID=<slug> "
"for this session; nothing injected."
)
raise Bail()
if plan_id:
if slug_is_valid(plan_id) and is_dir(plan_prefix + ".planning/" + plan_id):
resolved = plan_prefix + ".planning/" + plan_id
@@ -923,30 +930,14 @@ class Injector(object):
"single-session mode."
)
raise Bail()
if not plan_id:
plan_n = 1 if is_file(plan_prefix + "task_plan.md") else 0
try:
names = sorted(os.listdir(plan_prefix + ".planning"))
except OSError:
names = []
for name in names:
if name.startswith("."):
continue
if not is_file(plan_prefix + ".planning/" + name + "/task_plan.md"):
continue
if not slug_is_valid(name):
continue
plan_n += 1
if plan_n > 1:
break
if plan_n > 1:
if context == "userprompt":
self.echo(
"[planning-with-files] Multiple plans are available while session "
"isolation is armed. Set PLAN_ID=<slug> for this session; nothing "
"injected."
)
raise Bail()
if ambiguous:
if context == "userprompt":
self.echo(
"[planning-with-files] Multiple plans are available while session "
"isolation is armed. Set PLAN_ID=<slug> for this session; nothing "
"injected."
)
raise Bail()
if not explicit:
nested = []
@@ -1326,6 +1317,23 @@ def inject(context, env=None):
# Twin of resolve-plan-dir.sh (the shared resolver the dispatcher uses).
# --------------------------------------------------------------------------
def plan_is_ambiguous(plan_root, project_root, plan_id=""):
"""A project pointer or mtime cannot bind a session to multiple plans."""
if plan_id:
return False
count = 1 if is_dir(plan_root + "/sessions") and is_file(project_root + "/task_plan.md") else 0
try:
names = os.listdir(plan_root)
except OSError:
names = []
for name in names:
if slug_is_valid(name) and is_file(plan_root + "/" + name + "/task_plan.md"):
count += 1
if count > 1:
return True
return False
def resolve_plan_dir(env=None):
"""Return (spelled, filesystem) paths of the plan directory, or ("", "").
@@ -1360,6 +1368,9 @@ def resolve_plan_dir(env=None):
return found(plan_id)
return ("", "")
if plan_is_ambiguous(fs_root, pin if pin else "."):
return ("", "")
active_file = fs_root + "/.active_plan"
if is_file(active_file):
try:
@@ -1441,6 +1452,8 @@ class ClaudeDispatcher(object):
return (spelled, fs)
if self.env.get("PLAN_ID", "") or self.env.get("PWF_PLAN_ROOT", ""):
return ("", "")
if plan_is_ambiguous(".planning", "."):
return ("", "")
if is_file("task_plan.md"):
return (".", ".")
return ("", "")
@@ -254,6 +254,29 @@ is_within_root() {
# The .active_plan pointer, the newest-by-mtime fallback, and the legacy root
# task_plan.md are cwd GUESSES — only guesses are subject to the nested-root
# conflict check below.
# Shared .active_plan and directory mtime cannot identify this session's plan.
# Check before selection or preflight, even when isolation was never armed.
PLAN_AMBIGUOUS=0
if [ -z "${PLAN_ID:-}" ]; then
PLAN_COUNT=0
if [ -d "${PLAN_PREFIX}.planning/sessions" ] && [ -f "${PLAN_PREFIX}task_plan.md" ]; then
PLAN_COUNT=1
fi
for plan_candidate in "${PLAN_PREFIX}".planning/*/task_plan.md; do
[ -f "$plan_candidate" ] || continue
plan_candidate_dir="${plan_candidate%/task_plan.md}"
slug_is_valid "${plan_candidate_dir##*/}" || continue
PLAN_COUNT=$((PLAN_COUNT + 1))
if [ "$PLAN_COUNT" -gt 1 ]; then PLAN_AMBIGUOUS=1; break; fi
done
fi
if [ "$PLAN_AMBIGUOUS" = "1" ] && { [ "$CONTEXT" = "preflight" ] || [ ! -d "${PLAN_PREFIX}.planning/sessions" ]; }; then
if [ "$CONTEXT" = "userprompt" ]; then
echo "[planning-with-files] Multiple plans are available. Set PLAN_ID=<slug> for this session; nothing injected."
fi
exit 0
fi
RESOLVED=""
SCOPE=""
EXPLICIT=0
@@ -442,27 +465,13 @@ PY
exit 0
fi
# An attachment admits a session but does not select one of several plans.
# When isolation is armed, require PLAN_ID if more than one live same-root
# candidate exists. PWF_PLAN_ROOT selects the project root, not a plan
# within that root.
if [ -z "${PLAN_ID:-}" ]; then
SESSION_PLAN_N=0
[ -f "${PLAN_PREFIX}task_plan.md" ] && SESSION_PLAN_N=1
for candidate in "${PLAN_PREFIX}".planning/*/task_plan.md; do
[ -f "$candidate" ] || continue
candidate_dir="${candidate%/task_plan.md}"
candidate_slug="${candidate_dir##*/}"
slug_is_valid "$candidate_slug" || continue
SESSION_PLAN_N=$((SESSION_PLAN_N + 1))
[ "$SESSION_PLAN_N" -gt 1 ] && break
done
if [ "$SESSION_PLAN_N" -gt 1 ]; then
if [ "$CONTEXT" = "userprompt" ]; then
echo "[planning-with-files] Multiple plans are available while session isolation is armed. Set PLAN_ID=<slug> for this session; nothing injected."
fi
exit 0
# Attachment admits a session but does not select its plan. Preserve the
# attachment-first notice above for sessions that never opted in.
if [ "$PLAN_AMBIGUOUS" = "1" ]; then
if [ "$CONTEXT" = "userprompt" ]; then
echo "[planning-with-files] Multiple plans are available while session isolation is armed. Set PLAN_ID=<slug> for this session; nothing injected."
fi
exit 0
fi
fi
@@ -13,7 +13,8 @@
# junction/symlink escape; slug validation alone blocks textual traversal.
param(
[string]$PlanRoot = (Join-Path (Get-Location) ".planning")
[string]$PlanRoot = (Join-Path (Get-Location) ".planning"),
[switch]$CheckAmbiguity
)
$projectRoot = (Get-Location).Path
@@ -137,6 +138,30 @@ $activeFile = Join-Path $PlanRoot ".active_plan"
# at rc=0. Emptiness is the fail-closed signal on this channel, matching
# resolve-plan-dir.sh and the PWF_PLAN_ROOT pin. An empty $env:PLAN_ID is
# falsy here and still means "unset".
# The optional probe distinguishes ambiguity from a legacy-root fallback.
# Multiple named plans require PLAN_ID even without a sessions directory.
$planCount = 0
if (-not $env:PLAN_ID) {
if ((Test-Path -LiteralPath (Join-Path $PlanRoot "sessions") -PathType Container) -and
(Test-Path -LiteralPath (Join-Path $projectRoot "task_plan.md") -PathType Leaf)) {
$planCount = 1
}
if (Test-Path -LiteralPath $PlanRoot -PathType Container) {
foreach ($entry in (Get-ChildItem -LiteralPath $PlanRoot -Directory -ErrorAction SilentlyContinue)) {
if ((Test-ValidSlug $entry.Name) -and
(Test-Path -LiteralPath (Join-Path $entry.FullName "task_plan.md") -PathType Leaf)) {
$planCount++
if ($planCount -gt 1) { break }
}
}
}
}
if ($CheckAmbiguity) {
if ($planCount -gt 1) { Write-Output "PWF_PLAN_AMBIGUOUS_V1" }
exit 0
}
if ($planCount -gt 1) { exit 0 }
if ($env:PLAN_ID) {
if (Test-ValidSlug $env:PLAN_ID) {
$candidate = Join-Path $PlanRoot $env:PLAN_ID
@@ -15,6 +15,13 @@
set -u
# Optional probe distinguishes ambiguity from the empty legacy-root result.
# Both modes keep stdout data-only and always exit zero.
CHECK_AMBIGUITY=0
if [ "${1:-}" = "--check-ambiguity" ]; then
CHECK_AMBIGUITY=1
shift
fi
PLAN_ROOT="${1:-${PWD}/.planning}"
# --- PWF_PLAN_ROOT: absolute plan-root binding (issue #212). ---
@@ -321,6 +328,29 @@ resolve_latest_dir() {
# the fail-closed signal on this channel, exactly as the PWF_PLAN_ROOT guard
# above already does it; a non-zero status would kill callers running under
# set -e for a condition that is not an internal error.
# A shared pointer or mtime is not a per-session binding (issue #240).
# Count conservatively, just like injection: slug-valid live plan files.
# The legacy root joins the count only when session isolation is armed.
PLAN_AMBIGUOUS=0
if [ -z "${PLAN_ID:-}" ]; then
PLAN_COUNT=0
if [ -d "${PLAN_ROOT}/sessions" ] && [ -f "${PWF_ROOT_PIN:-.}/task_plan.md" ]; then
PLAN_COUNT=1
fi
for plan_candidate in "${PLAN_ROOT}"/*/task_plan.md; do
[ -f "$plan_candidate" ] || continue
plan_candidate_dir="${plan_candidate%/task_plan.md}"
slug_is_valid "${plan_candidate_dir##*/}" || continue
PLAN_COUNT=$((PLAN_COUNT + 1))
if [ "$PLAN_COUNT" -gt 1 ]; then PLAN_AMBIGUOUS=1; break; fi
done
fi
if [ "$CHECK_AMBIGUITY" = "1" ]; then
[ "$PLAN_AMBIGUOUS" = "1" ] && printf '%s\n' 'PWF_PLAN_AMBIGUOUS_V1'
exit 0
fi
[ "$PLAN_AMBIGUOUS" = "1" ] && exit 0
if [ -n "${PLAN_ID:-}" ]; then
resolve_from_env && exit 0
exit 0
@@ -404,6 +404,7 @@ function Resolve-PlanFile {
$resolver = Join-Path $PSScriptRoot "resolve-plan-dir.ps1"
if (-not (Test-Path -LiteralPath $resolver -PathType Leaf)) { return $null }
$resolvedDir = @(& $resolver | Where-Object { $_ }) | Select-Object -First 1
if (-not $resolvedDir -and ((& $resolver -CheckAmbiguity) -eq "PWF_PLAN_AMBIGUOUS_V1")) { return $null }
if ($resolvedDir) {
$planFile = Join-Path $resolvedDir "task_plan.md"
return (Resolve-ContainedPlanFile -Candidate $planFile -ExpectedDirectory $resolvedDir)
@@ -45,6 +45,10 @@ resolve_plan_file() {
plan_dir=""
if [ -f "${RESOLVER}" ]; then
plan_dir="$(sh "${RESOLVER}" 2>/dev/null)"
if [ -z "$plan_dir" ] && [ "$(sh "${RESOLVER}" --check-ambiguity 2>/dev/null)" = "PWF_PLAN_AMBIGUOUS_V1" ]; then
printf "[plan-attest] Multiple plans are available. Set PLAN_ID=<slug>; nothing was attested.\n" >&2
return 1
fi
fi
if [ -n "${plan_dir}" ] && [ -f "${plan_dir}/task_plan.md" ]; then
printf "%s\n" "${plan_dir}/task_plan.md"
@@ -59,12 +59,9 @@ Windows runs the shell chain through Git Bash and nothing else:
one turn-marker slot and one progress-guard slot per plan.
Known, accepted differences from the shell reference:
* Two plan directories with the same whole-second mtime, no .active_plan
and no PLAN_ID: the reference picks the first in the shell's glob order
(locale collation), this twin the first in code-point order. The same
collation difference can change which three of four or more nested
projects the ambiguity notice names. A machine runs one implementation,
so neither choice flips within a session.
* Shell glob order follows locale collation; this twin uses code-point
order. This can change which three of four or more nested projects the
ambiguity notice names.
* BSD sed (macOS) appends a newline to a progress.md whose last line has
none; GNU sed and this twin do not.
* With PWF_PLAN_ROOT set under Git Bash the shell sees the pin as typed
@@ -837,6 +834,16 @@ class Injector(object):
scope = ""
explicit = bool(plan_prefix)
plan_id = env.get("PLAN_ID", "")
ambiguous = plan_is_ambiguous(
plan_prefix + ".planning", plan_root_pin if plan_root_pin else ".", plan_id
)
if ambiguous and (context == "preflight" or not is_dir(plan_prefix + ".planning/sessions")):
if context == "userprompt":
self.echo(
"[planning-with-files] Multiple plans are available. Set PLAN_ID=<slug> "
"for this session; nothing injected."
)
raise Bail()
if plan_id:
if slug_is_valid(plan_id) and is_dir(plan_prefix + ".planning/" + plan_id):
resolved = plan_prefix + ".planning/" + plan_id
@@ -923,30 +930,14 @@ class Injector(object):
"single-session mode."
)
raise Bail()
if not plan_id:
plan_n = 1 if is_file(plan_prefix + "task_plan.md") else 0
try:
names = sorted(os.listdir(plan_prefix + ".planning"))
except OSError:
names = []
for name in names:
if name.startswith("."):
continue
if not is_file(plan_prefix + ".planning/" + name + "/task_plan.md"):
continue
if not slug_is_valid(name):
continue
plan_n += 1
if plan_n > 1:
break
if plan_n > 1:
if context == "userprompt":
self.echo(
"[planning-with-files] Multiple plans are available while session "
"isolation is armed. Set PLAN_ID=<slug> for this session; nothing "
"injected."
)
raise Bail()
if ambiguous:
if context == "userprompt":
self.echo(
"[planning-with-files] Multiple plans are available while session "
"isolation is armed. Set PLAN_ID=<slug> for this session; nothing "
"injected."
)
raise Bail()
if not explicit:
nested = []
@@ -1326,6 +1317,23 @@ def inject(context, env=None):
# Twin of resolve-plan-dir.sh (the shared resolver the dispatcher uses).
# --------------------------------------------------------------------------
def plan_is_ambiguous(plan_root, project_root, plan_id=""):
"""A project pointer or mtime cannot bind a session to multiple plans."""
if plan_id:
return False
count = 1 if is_dir(plan_root + "/sessions") and is_file(project_root + "/task_plan.md") else 0
try:
names = os.listdir(plan_root)
except OSError:
names = []
for name in names:
if slug_is_valid(name) and is_file(plan_root + "/" + name + "/task_plan.md"):
count += 1
if count > 1:
return True
return False
def resolve_plan_dir(env=None):
"""Return (spelled, filesystem) paths of the plan directory, or ("", "").
@@ -1360,6 +1368,9 @@ def resolve_plan_dir(env=None):
return found(plan_id)
return ("", "")
if plan_is_ambiguous(fs_root, pin if pin else "."):
return ("", "")
active_file = fs_root + "/.active_plan"
if is_file(active_file):
try:
@@ -1441,6 +1452,8 @@ class ClaudeDispatcher(object):
return (spelled, fs)
if self.env.get("PLAN_ID", "") or self.env.get("PWF_PLAN_ROOT", ""):
return ("", "")
if plan_is_ambiguous(".planning", "."):
return ("", "")
if is_file("task_plan.md"):
return (".", ".")
return ("", "")
@@ -254,6 +254,29 @@ is_within_root() {
# The .active_plan pointer, the newest-by-mtime fallback, and the legacy root
# task_plan.md are cwd GUESSES — only guesses are subject to the nested-root
# conflict check below.
# Shared .active_plan and directory mtime cannot identify this session's plan.
# Check before selection or preflight, even when isolation was never armed.
PLAN_AMBIGUOUS=0
if [ -z "${PLAN_ID:-}" ]; then
PLAN_COUNT=0
if [ -d "${PLAN_PREFIX}.planning/sessions" ] && [ -f "${PLAN_PREFIX}task_plan.md" ]; then
PLAN_COUNT=1
fi
for plan_candidate in "${PLAN_PREFIX}".planning/*/task_plan.md; do
[ -f "$plan_candidate" ] || continue
plan_candidate_dir="${plan_candidate%/task_plan.md}"
slug_is_valid "${plan_candidate_dir##*/}" || continue
PLAN_COUNT=$((PLAN_COUNT + 1))
if [ "$PLAN_COUNT" -gt 1 ]; then PLAN_AMBIGUOUS=1; break; fi
done
fi
if [ "$PLAN_AMBIGUOUS" = "1" ] && { [ "$CONTEXT" = "preflight" ] || [ ! -d "${PLAN_PREFIX}.planning/sessions" ]; }; then
if [ "$CONTEXT" = "userprompt" ]; then
echo "[planning-with-files] Multiple plans are available. Set PLAN_ID=<slug> for this session; nothing injected."
fi
exit 0
fi
RESOLVED=""
SCOPE=""
EXPLICIT=0
@@ -442,27 +465,13 @@ PY
exit 0
fi
# An attachment admits a session but does not select one of several plans.
# When isolation is armed, require PLAN_ID if more than one live same-root
# candidate exists. PWF_PLAN_ROOT selects the project root, not a plan
# within that root.
if [ -z "${PLAN_ID:-}" ]; then
SESSION_PLAN_N=0
[ -f "${PLAN_PREFIX}task_plan.md" ] && SESSION_PLAN_N=1
for candidate in "${PLAN_PREFIX}".planning/*/task_plan.md; do
[ -f "$candidate" ] || continue
candidate_dir="${candidate%/task_plan.md}"
candidate_slug="${candidate_dir##*/}"
slug_is_valid "$candidate_slug" || continue
SESSION_PLAN_N=$((SESSION_PLAN_N + 1))
[ "$SESSION_PLAN_N" -gt 1 ] && break
done
if [ "$SESSION_PLAN_N" -gt 1 ]; then
if [ "$CONTEXT" = "userprompt" ]; then
echo "[planning-with-files] Multiple plans are available while session isolation is armed. Set PLAN_ID=<slug> for this session; nothing injected."
fi
exit 0
# Attachment admits a session but does not select its plan. Preserve the
# attachment-first notice above for sessions that never opted in.
if [ "$PLAN_AMBIGUOUS" = "1" ]; then
if [ "$CONTEXT" = "userprompt" ]; then
echo "[planning-with-files] Multiple plans are available while session isolation is armed. Set PLAN_ID=<slug> for this session; nothing injected."
fi
exit 0
fi
fi
@@ -13,7 +13,8 @@
# junction/symlink escape; slug validation alone blocks textual traversal.
param(
[string]$PlanRoot = (Join-Path (Get-Location) ".planning")
[string]$PlanRoot = (Join-Path (Get-Location) ".planning"),
[switch]$CheckAmbiguity
)
$projectRoot = (Get-Location).Path
@@ -137,6 +138,30 @@ $activeFile = Join-Path $PlanRoot ".active_plan"
# at rc=0. Emptiness is the fail-closed signal on this channel, matching
# resolve-plan-dir.sh and the PWF_PLAN_ROOT pin. An empty $env:PLAN_ID is
# falsy here and still means "unset".
# The optional probe distinguishes ambiguity from a legacy-root fallback.
# Multiple named plans require PLAN_ID even without a sessions directory.
$planCount = 0
if (-not $env:PLAN_ID) {
if ((Test-Path -LiteralPath (Join-Path $PlanRoot "sessions") -PathType Container) -and
(Test-Path -LiteralPath (Join-Path $projectRoot "task_plan.md") -PathType Leaf)) {
$planCount = 1
}
if (Test-Path -LiteralPath $PlanRoot -PathType Container) {
foreach ($entry in (Get-ChildItem -LiteralPath $PlanRoot -Directory -ErrorAction SilentlyContinue)) {
if ((Test-ValidSlug $entry.Name) -and
(Test-Path -LiteralPath (Join-Path $entry.FullName "task_plan.md") -PathType Leaf)) {
$planCount++
if ($planCount -gt 1) { break }
}
}
}
}
if ($CheckAmbiguity) {
if ($planCount -gt 1) { Write-Output "PWF_PLAN_AMBIGUOUS_V1" }
exit 0
}
if ($planCount -gt 1) { exit 0 }
if ($env:PLAN_ID) {
if (Test-ValidSlug $env:PLAN_ID) {
$candidate = Join-Path $PlanRoot $env:PLAN_ID
@@ -15,6 +15,13 @@
set -u
# Optional probe distinguishes ambiguity from the empty legacy-root result.
# Both modes keep stdout data-only and always exit zero.
CHECK_AMBIGUITY=0
if [ "${1:-}" = "--check-ambiguity" ]; then
CHECK_AMBIGUITY=1
shift
fi
PLAN_ROOT="${1:-${PWD}/.planning}"
# --- PWF_PLAN_ROOT: absolute plan-root binding (issue #212). ---
@@ -321,6 +328,29 @@ resolve_latest_dir() {
# the fail-closed signal on this channel, exactly as the PWF_PLAN_ROOT guard
# above already does it; a non-zero status would kill callers running under
# set -e for a condition that is not an internal error.
# A shared pointer or mtime is not a per-session binding (issue #240).
# Count conservatively, just like injection: slug-valid live plan files.
# The legacy root joins the count only when session isolation is armed.
PLAN_AMBIGUOUS=0
if [ -z "${PLAN_ID:-}" ]; then
PLAN_COUNT=0
if [ -d "${PLAN_ROOT}/sessions" ] && [ -f "${PWF_ROOT_PIN:-.}/task_plan.md" ]; then
PLAN_COUNT=1
fi
for plan_candidate in "${PLAN_ROOT}"/*/task_plan.md; do
[ -f "$plan_candidate" ] || continue
plan_candidate_dir="${plan_candidate%/task_plan.md}"
slug_is_valid "${plan_candidate_dir##*/}" || continue
PLAN_COUNT=$((PLAN_COUNT + 1))
if [ "$PLAN_COUNT" -gt 1 ]; then PLAN_AMBIGUOUS=1; break; fi
done
fi
if [ "$CHECK_AMBIGUITY" = "1" ]; then
[ "$PLAN_AMBIGUOUS" = "1" ] && printf '%s\n' 'PWF_PLAN_AMBIGUOUS_V1'
exit 0
fi
[ "$PLAN_AMBIGUOUS" = "1" ] && exit 0
if [ -n "${PLAN_ID:-}" ]; then
resolve_from_env && exit 0
exit 0
@@ -404,6 +404,7 @@ function Resolve-PlanFile {
$resolver = Join-Path $PSScriptRoot "resolve-plan-dir.ps1"
if (-not (Test-Path -LiteralPath $resolver -PathType Leaf)) { return $null }
$resolvedDir = @(& $resolver | Where-Object { $_ }) | Select-Object -First 1
if (-not $resolvedDir -and ((& $resolver -CheckAmbiguity) -eq "PWF_PLAN_AMBIGUOUS_V1")) { return $null }
if ($resolvedDir) {
$planFile = Join-Path $resolvedDir "task_plan.md"
return (Resolve-ContainedPlanFile -Candidate $planFile -ExpectedDirectory $resolvedDir)
@@ -45,6 +45,10 @@ resolve_plan_file() {
plan_dir=""
if [ -f "${RESOLVER}" ]; then
plan_dir="$(sh "${RESOLVER}" 2>/dev/null)"
if [ -z "$plan_dir" ] && [ "$(sh "${RESOLVER}" --check-ambiguity 2>/dev/null)" = "PWF_PLAN_AMBIGUOUS_V1" ]; then
printf "[plan-attest] Multiple plans are available. Set PLAN_ID=<slug>; nothing was attested.\n" >&2
return 1
fi
fi
if [ -n "${plan_dir}" ] && [ -f "${plan_dir}/task_plan.md" ]; then
printf "%s\n" "${plan_dir}/task_plan.md"
@@ -59,12 +59,9 @@ Windows runs the shell chain through Git Bash and nothing else:
one turn-marker slot and one progress-guard slot per plan.
Known, accepted differences from the shell reference:
* Two plan directories with the same whole-second mtime, no .active_plan
and no PLAN_ID: the reference picks the first in the shell's glob order
(locale collation), this twin the first in code-point order. The same
collation difference can change which three of four or more nested
projects the ambiguity notice names. A machine runs one implementation,
so neither choice flips within a session.
* Shell glob order follows locale collation; this twin uses code-point
order. This can change which three of four or more nested projects the
ambiguity notice names.
* BSD sed (macOS) appends a newline to a progress.md whose last line has
none; GNU sed and this twin do not.
* With PWF_PLAN_ROOT set under Git Bash the shell sees the pin as typed
@@ -837,6 +834,16 @@ class Injector(object):
scope = ""
explicit = bool(plan_prefix)
plan_id = env.get("PLAN_ID", "")
ambiguous = plan_is_ambiguous(
plan_prefix + ".planning", plan_root_pin if plan_root_pin else ".", plan_id
)
if ambiguous and (context == "preflight" or not is_dir(plan_prefix + ".planning/sessions")):
if context == "userprompt":
self.echo(
"[planning-with-files] Multiple plans are available. Set PLAN_ID=<slug> "
"for this session; nothing injected."
)
raise Bail()
if plan_id:
if slug_is_valid(plan_id) and is_dir(plan_prefix + ".planning/" + plan_id):
resolved = plan_prefix + ".planning/" + plan_id
@@ -923,30 +930,14 @@ class Injector(object):
"single-session mode."
)
raise Bail()
if not plan_id:
plan_n = 1 if is_file(plan_prefix + "task_plan.md") else 0
try:
names = sorted(os.listdir(plan_prefix + ".planning"))
except OSError:
names = []
for name in names:
if name.startswith("."):
continue
if not is_file(plan_prefix + ".planning/" + name + "/task_plan.md"):
continue
if not slug_is_valid(name):
continue
plan_n += 1
if plan_n > 1:
break
if plan_n > 1:
if context == "userprompt":
self.echo(
"[planning-with-files] Multiple plans are available while session "
"isolation is armed. Set PLAN_ID=<slug> for this session; nothing "
"injected."
)
raise Bail()
if ambiguous:
if context == "userprompt":
self.echo(
"[planning-with-files] Multiple plans are available while session "
"isolation is armed. Set PLAN_ID=<slug> for this session; nothing "
"injected."
)
raise Bail()
if not explicit:
nested = []
@@ -1326,6 +1317,23 @@ def inject(context, env=None):
# Twin of resolve-plan-dir.sh (the shared resolver the dispatcher uses).
# --------------------------------------------------------------------------
def plan_is_ambiguous(plan_root, project_root, plan_id=""):
"""A project pointer or mtime cannot bind a session to multiple plans."""
if plan_id:
return False
count = 1 if is_dir(plan_root + "/sessions") and is_file(project_root + "/task_plan.md") else 0
try:
names = os.listdir(plan_root)
except OSError:
names = []
for name in names:
if slug_is_valid(name) and is_file(plan_root + "/" + name + "/task_plan.md"):
count += 1
if count > 1:
return True
return False
def resolve_plan_dir(env=None):
"""Return (spelled, filesystem) paths of the plan directory, or ("", "").
@@ -1360,6 +1368,9 @@ def resolve_plan_dir(env=None):
return found(plan_id)
return ("", "")
if plan_is_ambiguous(fs_root, pin if pin else "."):
return ("", "")
active_file = fs_root + "/.active_plan"
if is_file(active_file):
try:
@@ -1441,6 +1452,8 @@ class ClaudeDispatcher(object):
return (spelled, fs)
if self.env.get("PLAN_ID", "") or self.env.get("PWF_PLAN_ROOT", ""):
return ("", "")
if plan_is_ambiguous(".planning", "."):
return ("", "")
if is_file("task_plan.md"):
return (".", ".")
return ("", "")
@@ -254,6 +254,29 @@ is_within_root() {
# The .active_plan pointer, the newest-by-mtime fallback, and the legacy root
# task_plan.md are cwd GUESSES — only guesses are subject to the nested-root
# conflict check below.
# Shared .active_plan and directory mtime cannot identify this session's plan.
# Check before selection or preflight, even when isolation was never armed.
PLAN_AMBIGUOUS=0
if [ -z "${PLAN_ID:-}" ]; then
PLAN_COUNT=0
if [ -d "${PLAN_PREFIX}.planning/sessions" ] && [ -f "${PLAN_PREFIX}task_plan.md" ]; then
PLAN_COUNT=1
fi
for plan_candidate in "${PLAN_PREFIX}".planning/*/task_plan.md; do
[ -f "$plan_candidate" ] || continue
plan_candidate_dir="${plan_candidate%/task_plan.md}"
slug_is_valid "${plan_candidate_dir##*/}" || continue
PLAN_COUNT=$((PLAN_COUNT + 1))
if [ "$PLAN_COUNT" -gt 1 ]; then PLAN_AMBIGUOUS=1; break; fi
done
fi
if [ "$PLAN_AMBIGUOUS" = "1" ] && { [ "$CONTEXT" = "preflight" ] || [ ! -d "${PLAN_PREFIX}.planning/sessions" ]; }; then
if [ "$CONTEXT" = "userprompt" ]; then
echo "[planning-with-files] Multiple plans are available. Set PLAN_ID=<slug> for this session; nothing injected."
fi
exit 0
fi
RESOLVED=""
SCOPE=""
EXPLICIT=0
@@ -442,27 +465,13 @@ PY
exit 0
fi
# An attachment admits a session but does not select one of several plans.
# When isolation is armed, require PLAN_ID if more than one live same-root
# candidate exists. PWF_PLAN_ROOT selects the project root, not a plan
# within that root.
if [ -z "${PLAN_ID:-}" ]; then
SESSION_PLAN_N=0
[ -f "${PLAN_PREFIX}task_plan.md" ] && SESSION_PLAN_N=1
for candidate in "${PLAN_PREFIX}".planning/*/task_plan.md; do
[ -f "$candidate" ] || continue
candidate_dir="${candidate%/task_plan.md}"
candidate_slug="${candidate_dir##*/}"
slug_is_valid "$candidate_slug" || continue
SESSION_PLAN_N=$((SESSION_PLAN_N + 1))
[ "$SESSION_PLAN_N" -gt 1 ] && break
done
if [ "$SESSION_PLAN_N" -gt 1 ]; then
if [ "$CONTEXT" = "userprompt" ]; then
echo "[planning-with-files] Multiple plans are available while session isolation is armed. Set PLAN_ID=<slug> for this session; nothing injected."
fi
exit 0
# Attachment admits a session but does not select its plan. Preserve the
# attachment-first notice above for sessions that never opted in.
if [ "$PLAN_AMBIGUOUS" = "1" ]; then
if [ "$CONTEXT" = "userprompt" ]; then
echo "[planning-with-files] Multiple plans are available while session isolation is armed. Set PLAN_ID=<slug> for this session; nothing injected."
fi
exit 0
fi
fi
@@ -13,7 +13,8 @@
# junction/symlink escape; slug validation alone blocks textual traversal.
param(
[string]$PlanRoot = (Join-Path (Get-Location) ".planning")
[string]$PlanRoot = (Join-Path (Get-Location) ".planning"),
[switch]$CheckAmbiguity
)
$projectRoot = (Get-Location).Path
@@ -137,6 +138,30 @@ $activeFile = Join-Path $PlanRoot ".active_plan"
# at rc=0. Emptiness is the fail-closed signal on this channel, matching
# resolve-plan-dir.sh and the PWF_PLAN_ROOT pin. An empty $env:PLAN_ID is
# falsy here and still means "unset".
# The optional probe distinguishes ambiguity from a legacy-root fallback.
# Multiple named plans require PLAN_ID even without a sessions directory.
$planCount = 0
if (-not $env:PLAN_ID) {
if ((Test-Path -LiteralPath (Join-Path $PlanRoot "sessions") -PathType Container) -and
(Test-Path -LiteralPath (Join-Path $projectRoot "task_plan.md") -PathType Leaf)) {
$planCount = 1
}
if (Test-Path -LiteralPath $PlanRoot -PathType Container) {
foreach ($entry in (Get-ChildItem -LiteralPath $PlanRoot -Directory -ErrorAction SilentlyContinue)) {
if ((Test-ValidSlug $entry.Name) -and
(Test-Path -LiteralPath (Join-Path $entry.FullName "task_plan.md") -PathType Leaf)) {
$planCount++
if ($planCount -gt 1) { break }
}
}
}
}
if ($CheckAmbiguity) {
if ($planCount -gt 1) { Write-Output "PWF_PLAN_AMBIGUOUS_V1" }
exit 0
}
if ($planCount -gt 1) { exit 0 }
if ($env:PLAN_ID) {
if (Test-ValidSlug $env:PLAN_ID) {
$candidate = Join-Path $PlanRoot $env:PLAN_ID
@@ -15,6 +15,13 @@
set -u
# Optional probe distinguishes ambiguity from the empty legacy-root result.
# Both modes keep stdout data-only and always exit zero.
CHECK_AMBIGUITY=0
if [ "${1:-}" = "--check-ambiguity" ]; then
CHECK_AMBIGUITY=1
shift
fi
PLAN_ROOT="${1:-${PWD}/.planning}"
# --- PWF_PLAN_ROOT: absolute plan-root binding (issue #212). ---
@@ -321,6 +328,29 @@ resolve_latest_dir() {
# the fail-closed signal on this channel, exactly as the PWF_PLAN_ROOT guard
# above already does it; a non-zero status would kill callers running under
# set -e for a condition that is not an internal error.
# A shared pointer or mtime is not a per-session binding (issue #240).
# Count conservatively, just like injection: slug-valid live plan files.
# The legacy root joins the count only when session isolation is armed.
PLAN_AMBIGUOUS=0
if [ -z "${PLAN_ID:-}" ]; then
PLAN_COUNT=0
if [ -d "${PLAN_ROOT}/sessions" ] && [ -f "${PWF_ROOT_PIN:-.}/task_plan.md" ]; then
PLAN_COUNT=1
fi
for plan_candidate in "${PLAN_ROOT}"/*/task_plan.md; do
[ -f "$plan_candidate" ] || continue
plan_candidate_dir="${plan_candidate%/task_plan.md}"
slug_is_valid "${plan_candidate_dir##*/}" || continue
PLAN_COUNT=$((PLAN_COUNT + 1))
if [ "$PLAN_COUNT" -gt 1 ]; then PLAN_AMBIGUOUS=1; break; fi
done
fi
if [ "$CHECK_AMBIGUITY" = "1" ]; then
[ "$PLAN_AMBIGUOUS" = "1" ] && printf '%s\n' 'PWF_PLAN_AMBIGUOUS_V1'
exit 0
fi
[ "$PLAN_AMBIGUOUS" = "1" ] && exit 0
if [ -n "${PLAN_ID:-}" ]; then
resolve_from_env && exit 0
exit 0
@@ -404,6 +404,7 @@ function Resolve-PlanFile {
$resolver = Join-Path $PSScriptRoot "resolve-plan-dir.ps1"
if (-not (Test-Path -LiteralPath $resolver -PathType Leaf)) { return $null }
$resolvedDir = @(& $resolver | Where-Object { $_ }) | Select-Object -First 1
if (-not $resolvedDir -and ((& $resolver -CheckAmbiguity) -eq "PWF_PLAN_AMBIGUOUS_V1")) { return $null }
if ($resolvedDir) {
$planFile = Join-Path $resolvedDir "task_plan.md"
return (Resolve-ContainedPlanFile -Candidate $planFile -ExpectedDirectory $resolvedDir)

Some files were not shown because too many files have changed in this diff Show More