fix: refuse ambiguous unpinned named plans (#240)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
+43
-30
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user