Compare commits

...
14 Commits
81 changed files with 12063 additions and 890 deletions
+7 -1
View File
@@ -28,7 +28,7 @@ hooks:
- type: command
command: "SH=\"${CLAUDE_SKILL_DIR}/scripts/skill-hook.sh\"; [ -f \"$SH\" ] || SH=$(ls \"$HOME/.claude/skills/planning-with-files/scripts/skill-hook.sh\" \"$HOME/.claude/plugins/marketplaces/planning-with-files/scripts/skill-hook.sh\" 2>/dev/null | head -1); [ -n \"$SH\" ] && [ -f \"$SH\" ] && sh \"$SH\" --event=precompact; exit 0"
metadata:
version: "3.17.2"
version: "3.18.2"
---
# Planning with Files
@@ -227,6 +227,12 @@ Helper scripts for automation:
- `scripts/attest-plan.sh` (and `.ps1`) — Lock the current `task_plan.md` content with a SHA-256 attestation (v2.37.0). Hooks then refuse to inject plan content if the file diverges from the attested hash. Use `--show` to print the stored hash, `--clear` to remove the attestation. See `/plan-attest` command.
- `scripts/plan-doctor.sh` — One-pass self-check for the mechanisms that fail silently (v3.6.0): plan resolution, hook injection, canonicalizer path shape, attestation state, install surfaces, per-fire hook latency. Run it whenever hooks seem quiet or after installing on a new machine. See `/plan-doctor` command.
### List saved plans
To find a task before resuming it, run `sh "<skill-dir>/scripts/set-active-plan.sh" --list` or, in Windows PowerShell, `& "<skill-dir>/scripts/set-active-plan.ps1" -List`. Replace `<skill-dir>` with this installed skill directory and keep your current directory at the project root.
This read-only command lists named plans and phase progress under the current directory's `.planning/`. `[active]` marks the shared default pointer; it does not bind a session. Concurrent tasks still require each host's `PLAN_ID` or separate worktrees.
### Parallel task workflow
For independent tasks in the same repository, create a named plan for each and pin each agent host to its own plan:
+266 -16
View File
@@ -1,27 +1,245 @@
# planning-with-files: set or display the active plan pointer (PowerShell).
# planning-with-files: set, display, or list the active plan pointer (PowerShell).
#
# Usage:
# .\set-active-plan.ps1 <plan_id> - pin .planning\.active_plan to plan_id
# .\set-active-plan.ps1 - print the current active plan (if any)
# .\set-active-plan.ps1 -List - list available named plans and phase counts
# .\set-active-plan.ps1 --list - equivalent to -List and -l
param(
[string]$PlanId = ""
[Parameter(Position = 0)]
[string]$PlanId = "",
[Alias('l', '-list')]
[switch]$List,
[Alias('h', '-help')]
[switch]$Help
)
$PlanRoot = Join-Path (Get-Location) ".planning"
$ProjectRoot = (Get-Location).Path
# Windows PowerShell 5.1 can silently change directories when -File inherits
# a cwd containing wildcard characters. Recover that physical cwd only for a
# direct invocation of this script; interactive Set-Location remains primary.
if ($PSVersionTable.PSVersion.Major -eq 5 -and
[System.Management.Automation.WildcardPattern]::ContainsWildcardCharacters([Environment]::CurrentDirectory)) {
$processArgs = [Environment]::GetCommandLineArgs()
for ($index = 0; $index -lt ($processArgs.Length - 1); $index++) {
if ($processArgs[$index] -ieq '-File') {
$entryScript = $processArgs[$index + 1]
if (-not [IO.Path]::IsPathRooted($entryScript)) {
$entryScript = Join-Path ([Environment]::CurrentDirectory) $entryScript
}
if ([IO.Path]::GetFullPath($entryScript) -eq [IO.Path]::GetFullPath($PSCommandPath)) {
$ProjectRoot = [Environment]::CurrentDirectory
}
break
}
}
}
$PlanRoot = Join-Path $ProjectRoot ".planning"
$ActiveFile = Join-Path $PlanRoot ".active_plan"
$script:IsWindowsHost = [Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT
# Resolve-Path can retain a junction's spelling. Like resolve-plan-dir.ps1,
# open the actual filesystem object before checking the project boundary.
# The Windows handle supports both directories and task_plan.md files.
function Get-FinalPath {
param([string]$Path)
if (-not ("PwfPlanListingNative" -as [type])) {
Add-Type -ErrorAction Stop -TypeDefinition @'
using System;
using System.ComponentModel;
using System.Runtime.InteropServices;
using System.Text;
using Microsoft.Win32.SafeHandles;
public static class PwfPlanListingNative {
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern SafeFileHandle CreateFileW(
string name, uint access, uint share, IntPtr security,
uint creation, uint flags, IntPtr template);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern uint GetFinalPathNameByHandleW(
SafeFileHandle handle, StringBuilder path, uint length, uint flags);
[DllImport("libc", SetLastError = true)]
private static extern IntPtr realpath(string path, IntPtr resolved);
[DllImport("libc")]
private static extern void free(IntPtr pointer);
public static string FinalPath(string path) {
if (Environment.OSVersion.Platform != PlatformID.Win32NT) {
IntPtr resolved = realpath(path, IntPtr.Zero);
if (resolved == IntPtr.Zero) throw new Win32Exception(Marshal.GetLastWin32Error());
try { return Marshal.PtrToStringAnsi(resolved); }
finally { free(resolved); }
}
// OPEN_EXISTING, FILE_SHARE_READ | WRITE | DELETE, BACKUP_SEMANTICS.
using (SafeFileHandle handle = CreateFileW(
path, 0, 7, IntPtr.Zero, 3, 0x02000000, IntPtr.Zero)) {
if (handle.IsInvalid) throw new Win32Exception(Marshal.GetLastWin32Error());
StringBuilder buffer = new StringBuilder(32768);
uint length = GetFinalPathNameByHandleW(handle, buffer, (uint)buffer.Capacity, 0);
if (length == 0 || length >= buffer.Capacity)
throw new Win32Exception(Marshal.GetLastWin32Error());
string result = buffer.ToString();
if (result.StartsWith(@"\\?\UNC\", StringComparison.OrdinalIgnoreCase))
return @"\\" + result.Substring(8);
if (result.StartsWith(@"\\?\", StringComparison.OrdinalIgnoreCase))
return result.Substring(4);
return result;
}
}
}
'@
}
return [PwfPlanListingNative]::FinalPath($Path)
}
function Test-WithinRoot {
param([string]$Path)
try {
$rootReal = (Get-FinalPath $ProjectRoot).TrimEnd('\', '/')
$pathReal = (Get-FinalPath $Path).TrimEnd('\', '/')
$comparison = [StringComparison]::Ordinal
if ($script:IsWindowsHost) { $comparison = [StringComparison]::OrdinalIgnoreCase }
return $pathReal.Equals($rootReal, $comparison) -or
$pathReal.StartsWith($rootReal + [IO.Path]::DirectorySeparatorChar, $comparison)
} catch {
return $false
}
}
function Test-ValidSlug {
param([string]$Name)
return $Name -cmatch '^[A-Za-z0-9_][A-Za-z0-9._-]*\z'
}
function Test-SafeActiveFile {
param([switch]$AllowLink)
$item = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
if (-not $item) { return $false }
# Reading may follow a verified in-project link; writing must not.
return -not $item.PSIsContainer -and
($AllowLink -or (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0)) -and
(Test-WithinRoot $ActiveFile)
}
function Get-CurrentActivePlan {
if (-not (Test-SafeActiveFile -AllowLink)) { return "" }
try {
$current = ([string](Get-Content -LiteralPath $ActiveFile -Raw -Encoding UTF8 -ErrorAction Stop)).Trim([char[]]"`r`n")
if (Test-ValidSlug $current) { return $current }
} catch { }
return ""
}
function Add-PhaseStatus {
param([hashtable]$Counts, $Phase)
if ($null -eq $Phase) { return }
$status = $Phase.Inline
if ($Phase.Primary) { $status = $Phase.Primary }
if ($status) { $Counts[$status]++ }
}
function Get-PhaseStatus {
param([string]$PlanFile)
$counts = @{ total = 0; complete = 0; in_progress = 0; pending = 0 }
$phase = $null
$fence = ""
$fenceLength = 0
# The shipped translations keep status identifiers in English. Unicode
# escapes preserve Windows PowerShell 5.1 compatibility without a BOM.
$phaseLabel = '(?:Phase|Fase|\u0627\u0644\u0645\u0631\u062d\u0644\u0629|\u9636\u6bb5|\u968e\u6bb5)'
$statusLabel = '(?:Status:|Estado:|\u0627\u0644\u062d\u0627\u0644\u0629:|\u72b6\u6001\uff1a|\u72c0\u614b\uff1a)'
# Read once; an empty file is a valid plan with zero phases.
$content = [string](Get-Content -LiteralPath $PlanFile -Raw -Encoding UTF8 -ErrorAction Stop)
foreach ($line in ($content -split '\r?\n')) {
if ($fence) {
$closing = '^ {0,3}' + [regex]::Escape($fence) + '{' + $fenceLength + ',}[ \t]*$'
if ($line -match $closing) { $fence = "" }
continue
}
if ($line -match '^ {0,3}(`{3,}|~{3,})') {
$fence = $Matches[1].Substring(0, 1)
$fenceLength = $Matches[1].Length
continue
}
if ($line -cmatch ('^ {0,3}###[ \t]+' + $phaseLabel + '[ \t]+[0-9]+(?:[^0-9A-Za-z_]|$)')) {
Add-PhaseStatus $counts $phase
$phase = @{ Primary = ""; Inline = "" }
$counts.total++
if ($line -cmatch '\[(complete|in_progress|pending)\]') {
$phase.Inline = $Matches[1]
}
continue
}
if ($line -match '^ {0,3}#{1,3}(?:[ \t]+|$)') {
Add-PhaseStatus $counts $phase
$phase = $null
continue
}
if ($null -ne $phase -and -not $phase.Primary -and
$line -cmatch ('^ {0,3}(?:-[ \t]+)?\*\*' + $statusLabel + '\*\*[ \t]+(complete|in_progress|pending)(?:[ \t]|$)')) {
$phase.Primary = $Matches[1]
}
}
Add-PhaseStatus $counts $phase
return "$($counts.complete)/$($counts.total) complete, $($counts.in_progress) in_progress, $($counts.pending) pending"
}
function Show-PlanList {
if (-not (Test-Path -LiteralPath $PlanRoot -PathType Container)) {
Write-Output "No planning directory found."
return
}
if (-not (Test-WithinRoot $PlanRoot)) {
Write-Error "Error: planning directory is outside the project or cannot be verified."
exit 1
}
$active = Get-CurrentActivePlan
Write-Output "Available plans:"
Write-Output "[active] marks the shared .active_plan pointer; listing does not bind this session."
$found = $false
foreach ($plan in (Get-ChildItem -LiteralPath $PlanRoot -Directory -ErrorAction SilentlyContinue | Sort-Object Name)) {
if (-not (Test-ValidSlug $plan.Name) -or -not (Test-WithinRoot $plan.FullName)) { continue }
$planFile = Join-Path $plan.FullName "task_plan.md"
if (-not (Test-Path -LiteralPath $planFile -PathType Leaf) -or -not (Test-WithinRoot $planFile)) { continue }
try { $status = Get-PhaseStatus $planFile } catch { continue }
$marker = ""
if ($plan.Name -ceq $active) { $marker = " [active]" }
Write-Output "- $($plan.Name)$marker - $status"
$found = $true
}
if (-not $found) { Write-Output "No named plans found." }
}
if ($Help -or $PlanId -eq "--help" -or $PlanId -eq "-h") {
Write-Output "Usage: set-active-plan.ps1 [-List|-l|--list|PLAN_ID]"
exit 0
}
if ($List -or $PlanId -eq "--list" -or $PlanId -eq "-l") {
if ($List -and $PlanId) {
Write-Error "Error: list plans or set PLAN_ID in separate calls."
exit 1
}
Show-PlanList
exit 0
}
if ($PlanId -eq "") {
if (Test-Path $ActiveFile) {
$current = (Get-Content $ActiveFile -Raw -Encoding UTF8).Trim()
$current = ""
if ((Test-Path -LiteralPath $PlanRoot -PathType Container) -and -not (Test-WithinRoot $PlanRoot)) {
Write-Error "Error: planning directory is outside the project or cannot be verified."
exit 1
}
if (Test-WithinRoot $PlanRoot) { $current = Get-CurrentActivePlan }
if ($current) {
$planDir = Join-Path $PlanRoot $current
if ($current -ne "" -and (Test-Path $planDir)) {
if ((Test-Path -LiteralPath $planDir -PathType Container) -and (Test-WithinRoot $planDir)) {
Write-Output "Active plan: $current"
Write-Output "Path: $planDir"
} elseif ($current -ne "") {
Write-Output "Active plan pointer: $current (directory not found - stale pointer)"
} else {
Write-Output "No active plan set."
Write-Output "Active plan pointer: $current (directory not found or outside project - stale pointer)"
}
} else {
Write-Output "No active plan set."
@@ -29,21 +247,53 @@ if ($PlanId -eq "") {
exit 0
}
if (-not (Test-ValidSlug $PlanId)) {
Write-Error "Error: invalid plan ID. Use letters, numbers, underscores, dots, or hyphens; start with a letter, number, or underscore."
exit 1
}
$PlanDir = Join-Path $PlanRoot $PlanId
if (-not (Test-Path $PlanDir)) {
if (-not (Test-Path -LiteralPath $PlanDir -PathType Container)) {
Write-Error "Error: plan directory not found: $PlanDir"
Write-Error "Run: init-session.sh `"$PlanId`" to create it, or check .planning\ for available plans."
exit 1
}
if (-not (Test-Path $PlanRoot)) {
New-Item -ItemType Directory -Path $PlanRoot -Force | Out-Null
if (-not (Test-WithinRoot $PlanRoot) -or -not (Test-WithinRoot $PlanDir)) {
Write-Error "Error: plan directory must remain within the project."
exit 1
}
$activeItem = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
if ($activeItem -and -not (Test-SafeActiveFile)) {
Write-Error "Error: the active plan pointer must be a regular file within the project."
exit 1
}
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($ActiveFile, $PlanId, $utf8NoBom)
# Replace the directory entry instead of truncating an existing inode: a
# hardlinked pointer must not overwrite another file. The exclusive temporary
# file lives beside the pointer, keeping replacement on the same filesystem.
$tempFile = Join-Path $PlanRoot ('.active_plan.' + [guid]::NewGuid().ToString('N') + '.tmp')
$createdTemp = $false
try {
$stream = [IO.File]::Open($tempFile, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
$createdTemp = $true
try {
$bytes = $utf8NoBom.GetBytes($PlanId)
$stream.Write($bytes, 0, $bytes.Length)
$stream.Flush()
} finally { $stream.Dispose() }
if ($activeItem) {
[IO.File]::Replace($tempFile, $ActiveFile, [NullString]::Value)
} else {
[IO.File]::Move($tempFile, $ActiveFile)
}
} catch {
Write-Error "Error: could not set the active plan pointer: $($_.Exception.Message)"
exit 1
} finally {
if ($createdTemp -and (Test-Path -LiteralPath $tempFile)) {
Remove-Item -LiteralPath $tempFile -Force -ErrorAction SilentlyContinue
}
}
Write-Output "Active plan set to: $PlanId"
Write-Output "Path: $PlanDir"
Write-Output ""
+323 -38
View File
@@ -1,50 +1,335 @@
#!/bin/sh
# planning-with-files: set or display the active plan pointer.
#
# Usage:
# set-active-plan.sh <plan_id> — pin .planning/.active_plan to plan_id
# set-active-plan.sh — print the current active plan (if any)
#
# The active plan is stored in .planning/.active_plan and is read by
# resolve-plan-dir.sh when no $PLAN_ID env var is set.
set -e
# List saved named plans, show the shared pointer, or change that pointer.
# Usage: set-active-plan.sh [--list|-l|PLAN_ID]
# Operates on the current project; listing never binds a host or injects data.
set -eu
PLAN_ROOT="${PWD}/.planning"
ACTIVE_FILE="${PLAN_ROOT}/.active_plan"
PWF_ROOT_PIN=""
# No args → show current active plan
if [ "${1:-}" = "" ]; then
if [ -f "${ACTIVE_FILE}" ]; then
plan_id="$(tr -d '\r\n' < "${ACTIVE_FILE}")"
if [ -n "${plan_id}" ] && [ -d "${PLAN_ROOT}/${plan_id}" ]; then
echo "Active plan: ${plan_id}"
echo "Path: ${PLAN_ROOT}/${plan_id}"
elif [ -n "${plan_id}" ]; then
echo "Active plan pointer: ${plan_id} (directory not found — stale pointer)"
else
echo "No active plan set."
fi
# Use the resolver canonicalization policy without running plan selection.
slug_is_valid() {
case "$1" in
'') return 1 ;;
*[!A-Za-z0-9._-]*) return 1 ;;
[A-Za-z0-9_]*) return 0 ;;
esac
return 1
}
# Pure-sh backslash-to-forward-slash normalizer; result lands in $NORM_OUT.
# Windows-native coreutils builds (e.g. C:\Program Files\coreutils on PATH
# ahead of Git's usr/bin) canonicalize MSYS-style /c/... input to C:\-style
# backslash output. The containment prefix match below is written with forward
# slashes, so without this normalization every canonical pair mismatches and
# resolution silently fails. On POSIX systems paths contain no backslash and
# this is the identity. A literal backslash in a Unix filename normalizes to
# "/" and at worst fails containment — the safe direction. No subshell, no
# fork: plain parameter expansion in a loop.
norm_slashes() {
NORM_OUT=""
_ns_rest="$1"
while :; do
case "${_ns_rest}" in
*\\*)
NORM_OUT="${NORM_OUT}${_ns_rest%%\\*}/"
_ns_rest="${_ns_rest#*\\}"
;;
*)
NORM_OUT="${NORM_OUT}${_ns_rest}"
break
;;
esac
done
}
# Return true when a candidate path names the Microsoft Store WindowsApps
# directory. Store app aliases are not stable interpreter binaries and may
# present as executable while refusing script execution. Matching is
# case-insensitive and works before or after Windows slash normalization.
is_windowsapps_path() {
norm_slashes "$1"
case "${NORM_OUT}" in
[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]|\
[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]/*|\
*/[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]|\
*/[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]/*) return 0 ;;
esac
return 1
}
# Select only an interpreter path the caller explicitly trusted.
# PWF_TRUSTED_PYTHON is preferred; PYTHON_BIN remains a compatibility alias.
# PATH discovery is intentionally forbidden because resolver hooks can run in
# repositories that control PATH. Windows-native absolute paths are converted
# with Git Bash's fixed system cygpath, never a PATH-selected shim.
trusted_python() {
for _tp_candidate in "${PWF_TRUSTED_PYTHON:-}" "${PYTHON_BIN:-}"; do
[ -n "${_tp_candidate}" ] || continue
case "${_tp_candidate}" in
\\\\*|//*) continue ;;
[A-Za-z]:[\\/]*)
is_windowsapps_path "${_tp_candidate}" && continue
_tp_cygpath="/usr/bin/cygpath.exe"
[ -f "${_tp_cygpath}" ] && [ -x "${_tp_cygpath}" ] || continue
_tp_candidate="$("${_tp_cygpath}" -u "${_tp_candidate}" 2>/dev/null)" \
|| continue
;;
/*) ;;
*) continue ;;
esac
is_windowsapps_path "${_tp_candidate}" && continue
[ -f "${_tp_candidate}" ] || continue
[ -x "${_tp_candidate}" ] || continue
printf "%s\n" "${_tp_candidate}"
return 0
done
return 1
}
# Portable path canonicalizer. realpath first (Linux, modern coreutils),
# then readlink -f (older GNU), then an explicitly trusted Python interpreter.
# Prints the canonical absolute path on success; prints nothing and returns 1
# on a full miss so containment fails closed. No Python spawn on the happy
# path: realpath/readlink cover Linux, WSL, Git-Bash, and modern macOS.
canonicalize() {
target="$1"
if command -v realpath >/dev/null 2>&1; then
out="$(realpath "${target}" 2>/dev/null)" && [ -n "${out}" ] && {
printf "%s\n" "${out}"; return 0; }
fi
if command -v readlink >/dev/null 2>&1; then
out="$(readlink -f "${target}" 2>/dev/null)" && [ -n "${out}" ] && {
printf "%s\n" "${out}"; return 0; }
fi
_canonical_python="$(trusted_python)" || _canonical_python=""
if [ -n "${_canonical_python}" ]; then
out="$("${_canonical_python}" -I -c "import os,sys;print(os.path.realpath(sys.argv[1]))" "${target}" 2>/dev/null)" \
&& [ -n "${out}" ] && { printf "%s\n" "${out}"; return 0; }
fi
return 1
}
# Containment guard (security A1.3): a resolved plan dir must canonicalize to a
# path under the project root (the CWD the script runs from). A symlink inside
# a valid slug dir pointing at /etc or outside the workspace would otherwise let
# the hooks hash and inject an arbitrary file. On any violation we return 1 so
# the caller treats the candidate as unresolved and falls back safely.
#
# The root canonicalizes via the relative token "." rather than the $PWD
# string. On some Windows/MSYS setups (8.3 short names, the /tmp mount alias)
# realpath("$PWD") and realpath(relative-candidate) resolve through different
# code paths and land on differently-spelled-but-equal targets, so the prefix
# match below fails and resolution silently goes dark. "." resolves through
# the same physical-cwd path candidates already use (same fix inject-plan.sh
# received earlier; the resolver kept the $PWD form until now). Both sides are
# backslash-normalized before comparison for Windows-native canonicalizers.
# The root is computed once per run: the newest-mtime scan calls this guard
# per plan dir, and each canonicalize costs a process spawn on Windows.
#
# With a PWF_PLAN_ROOT pin (issue #212) containment is checked against THAT
# root instead of the cwd: candidates arrive ${PWF_PLAN_ROOT}/-prefixed, so
# both sides canonicalize through the same path spelling. Unpinned keeps the
# relative "." root — byte-identical to the legacy check.
ROOT_REAL=""
ROOT_REAL_SET=0
is_within_root() {
candidate="$1"
if [ "${ROOT_REAL_SET}" = "0" ]; then
ROOT_REAL="$(canonicalize "${PWF_ROOT_PIN:-.}")" || ROOT_REAL=""
norm_slashes "${ROOT_REAL}"
ROOT_REAL="${NORM_OUT}"
ROOT_REAL_SET=1
fi
# Canonicalize the candidate through its cwd-RELATIVE form whenever it
# lives under ${PWD}. The candidate string is built from ${PWD} (an MSYS
# long-form spelling), while the root canonicalizes from "." (the process
# cwd, which a caller may have set with an 8.3 short-form string). A
# Windows-native realpath does not unify those spellings, so canonicalizing
# both sides from the same cwd base is the only spelling-stable comparison.
# The emitted result keeps the original absolute candidate — only the
# containment check uses the relative form.
# Pinned resolution skips the rewrite: candidate and root then share the
# ${PWF_PLAN_ROOT} spelling, so both canonicalize directly from it.
if [ -n "${PWF_ROOT_PIN}" ]; then
check_target="${candidate}"
else
echo "No active plan set."
case "${candidate}" in
"${PWD}"/*) check_target=".${candidate#"${PWD}"}" ;;
*) check_target="${candidate}" ;;
esac
fi
cand_real="$(canonicalize "${check_target}")" || cand_real=""
norm_slashes "${cand_real}"
cand_real="${NORM_OUT}"
if [ -z "${ROOT_REAL}" ] || [ -z "${cand_real}" ]; then
# Slug validation blocks textual traversal, but only successful
# canonicalization can rule out a symlink/junction escape.
return 1
fi
case "${cand_real}" in
"${ROOT_REAL}"|"${ROOT_REAL}"/*) return 0 ;;
*) return 1 ;;
esac
}
# Each phase contributes at most one status. An explicit Status line wins
# over an inline heading marker. Fenced examples and unrelated sections are
# not phases. Keep this parser aligned with the PowerShell helper.
phase_status() {
awk '
function finish() {
if (phase) {
status = primary != "" ? primary : inline_status
if (status == "complete") complete++
else if (status == "in_progress") in_progress++
else if (status == "pending") pending++
}
phase = 0; primary = ""; inline_status = ""
}
{
line = $0
sub(/\r$/, "", line)
trimmed = line
sub(/^ */, "", trimmed)
if (line ~ /^ ? ? ?```/ || line ~ /^ ? ? ?~~~/) {
marker = substr(trimmed, 1, 1)
run = 0
while (substr(trimmed, run + 1, 1) == marker) run++
if (fence == "") { fence = marker; fence_length = run }
else if (marker == fence && run >= fence_length && substr(trimmed, run + 1) ~ /^[ \t]*$/) fence = ""
next
}
if (fence != "") next
if (line ~ /^ ? ? ?###[ \t]+(Phase|Fase|المرحلة|阶段|階段)[ \t]+[0-9]+([^0-9A-Za-z_]|$)/) {
finish(); phase = 1; total++
if (match(line, /\[(complete|in_progress|pending)\]/))
inline_status = substr(line, RSTART + 1, RLENGTH - 2)
} else if (line ~ /^ ? ? ?(###|##|#)([ \t]|$)/) {
finish()
} else if (phase && primary == "" && line ~ /^ ? ? ?(-[ \t]+)?\*\*(Status:|Estado:|الحالة:|状态:|狀態:)\*\*[ \t]+(complete|in_progress|pending)([ \t]|$)/) {
sub(/^ ? ? ?(-[ \t]+)?\*\*(Status:|Estado:|الحالة:|状态:|狀態:)\*\*[ \t]+/, "", line)
sub(/[ \t].*$/, "", line)
primary = line
}
}
END {
finish()
printf "%d/%d complete, %d in_progress, %d pending", complete, total, in_progress, pending
}
' < "$1"
}
current_active() {
if [ -f "${ACTIVE_FILE}" ] && is_within_root "${ACTIVE_FILE}"; then
_current="$(tr '\r' '\n' < "${ACTIVE_FILE}")"
# Windows editors and older PowerShell defaults can leave a UTF-8 BOM.
# Treat it as an encoding marker, not part of the shared plan slug.
_utf8_bom="$(printf '\357\273\277')"
case "${_current}" in
"${_utf8_bom}"*) _current="${_current#"${_utf8_bom}"}" ;;
esac
slug_is_valid "${_current}" && printf '%s\n' "${_current}"
fi
return 0
}
list_plans() {
if [ ! -d "${PLAN_ROOT}" ]; then
printf '%s\n' 'No planning directory found.'
return 0
fi
if ! is_within_root "${PLAN_ROOT}"; then
printf '%s\n' 'Error: planning directory is outside the project or cannot be verified.' >&2
return 1
fi
_active="$(current_active)"
_found=0
printf '%s\n' 'Available plans:'
for _dir in "${PLAN_ROOT}"/*; do
[ -d "${_dir}" ] || continue
_id="${_dir##*/}"
slug_is_valid "${_id}" || continue
is_within_root "${_dir}" || continue
_plan_file="${_dir}/task_plan.md"
[ -f "${_plan_file}" ] && [ -r "${_plan_file}" ] || continue
is_within_root "${_plan_file}" || continue
_status="$(phase_status "${_plan_file}")" || continue
_marker=''
if [ "${_id}" = "${_active}" ]; then _marker=' [active]'; fi
printf '%s\n' "- ${_id}${_marker} - ${_status}"
_found=1
done
if [ "${_found}" -eq 0 ]; then
printf '%s\n' 'No named plans found.'
else
printf '%s\n' '[active] marks the shared default pointer. Set PLAN_ID to pin a session.'
fi
}
if [ "$#" -gt 1 ]; then
printf '%s\n' 'Error: list plans or set PLAN_ID in separate calls.' >&2
exit 1
fi
case "${1:-}" in
--list|-l) list_plans; exit $? ;;
--help|-h)
printf '%s\n' 'Usage: set-active-plan.sh [--list|PLAN_ID]' \
'Lists saved named plans in the current project without selecting a plan.'
exit 0 ;;
esac
if [ "${1:-}" = '' ]; then
if [ -d "${PLAN_ROOT}" ] && ! is_within_root "${PLAN_ROOT}"; then
printf '%s\n' 'Error: planning directory is outside the project or cannot be verified.' >&2
exit 1
fi
plan_id="$(current_active)"
if [ -n "${plan_id}" ] && [ -d "${PLAN_ROOT}/${plan_id}" ] && is_within_root "${PLAN_ROOT}/${plan_id}"; then
printf '%s\n' "Active plan: ${plan_id}" "Path: ${PLAN_ROOT}/${plan_id}"
elif [ -n "${plan_id}" ]; then
printf '%s\n' "Active plan pointer: ${plan_id} (directory not found or outside project - stale pointer)"
else
printf '%s\n' 'No active plan set.'
fi
exit 0
fi
PLAN_ID="$1"
PLAN_DIR="${PLAN_ROOT}/${PLAN_ID}"
if [ ! -d "${PLAN_DIR}" ]; then
echo "Error: plan directory not found: ${PLAN_DIR}" >&2
echo "Run: init-session.sh \"${PLAN_ID}\" to create it, or check .planning/ for available plans." >&2
if ! slug_is_valid "${PLAN_ID}"; then
printf '%s\n' 'Error: invalid plan ID. Use a named directory under .planning.' >&2
exit 1
fi
mkdir -p "${PLAN_ROOT}"
printf "%s\n" "${PLAN_ID}" > "${ACTIVE_FILE}"
echo "Active plan set to: ${PLAN_ID}"
echo "Path: ${PLAN_DIR}"
echo ""
echo "To pin this terminal session only:"
echo " export PLAN_ID=${PLAN_ID}"
PLAN_DIR="${PLAN_ROOT}/${PLAN_ID}"
if [ ! -d "${PLAN_DIR}" ]; then
printf '%s\n' "Error: plan directory not found: ${PLAN_DIR}" \
"Run: init-session.sh \"${PLAN_ID}\" to create it, or use --list to see available plans." >&2
exit 1
fi
if ! is_within_root "${PLAN_ROOT}" || ! is_within_root "${PLAN_DIR}"; then
printf '%s\n' 'Error: plan directory is outside the project or cannot be verified.' >&2
exit 1
fi
# A pre-existing pointer must itself be a contained regular file before writing.
if { [ -e "${ACTIVE_FILE}" ] || [ -L "${ACTIVE_FILE}" ]; } &&
{ [ -L "${ACTIVE_FILE}" ] || [ ! -f "${ACTIVE_FILE}" ] || ! is_within_root "${ACTIVE_FILE}"; }; then
printf '%s\n' 'Error: active plan pointer is not a safe file inside the project.' >&2
exit 1
fi
# Replace the pointer atomically instead of truncating a possible hardlink.
# mktemp creates a private, exclusive file beside the destination.
temp_file="$(mktemp "${PLAN_ROOT}/.active_plan.XXXXXX")" || {
printf '%s\n' 'Error: could not create the active plan pointer.' >&2
exit 1
}
trap 'rm -f "${temp_file}"' EXIT
trap 'exit 1' HUP INT TERM
printf '%s\n' "${PLAN_ID}" > "${temp_file}"
if ! mv -f "${temp_file}" "${ACTIVE_FILE}"; then
printf '%s\n' 'Error: could not replace the active plan pointer.' >&2
exit 1
fi
trap - EXIT HUP INT TERM
printf '%s\n' "Active plan set to: ${PLAN_ID}" "Path: ${PLAN_DIR}" '' \
'To pin this terminal session only:' " export PLAN_ID=${PLAN_ID}"
+1 -1
View File
@@ -10,7 +10,7 @@
"name": "planning-with-files",
"source": "./",
"description": "Persistent project planning files with lifecycle hooks that inject selected plan context. Automatic recovery uses project files only. Explicit catchup modes may read same-project local session records to emit aggregate counts or bounded nonce-framed excerpts. Optional gated mode can request continuation on capable hosts and never runs commands declared in Markdown. No network upload path. Installs across 60+ agents.",
"version": "3.17.2"
"version": "3.18.2"
}
]
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "planning-with-files",
"version": "3.17.2",
"version": "3.18.2",
"description": "Persistent project planning files with lifecycle hooks that inject selected plan context. Automatic recovery uses project files only. Explicit catchup modes may read same-project local session records to emit aggregate counts or bounded nonce-framed excerpts. Optional gated mode can request continuation on capable hosts and never runs commands declared in Markdown. No network upload path. Works across 60+ agents and includes Arabic, German, Spanish, Simplified Chinese, and Traditional Chinese.",
"author": {
"name": "OthmanAdi",
@@ -36,7 +36,7 @@ hooks:
- type: command
command: "SH=\"\"; for c in \"${PWF_SCRIPT_DIR}/skill-hook.sh\" \"${CLAUDE_SKILL_DIR}/scripts/skill-hook.sh\" \"${CODEBUDDY_PLUGIN_ROOT}/scripts/skill-hook.sh\" \"$HOME/.codebuddy/skills/planning-with-files/scripts/skill-hook.sh\" \"$HOME/.claude/skills/planning-with-files/scripts/skill-hook.sh\" \"$HOME/.claude/plugins/marketplaces/planning-with-files/scripts/skill-hook.sh\"; do [ -f \"$c\" ] && { SH=\"$c\"; break; }; done; [ -n \"$SH\" ] && sh \"$SH\" --event=precompact; exit 0"
metadata:
version: "3.17.2"
version: "3.18.2"
---
@@ -213,6 +213,12 @@ Helper scripts for automation:
- `scripts/check-complete.sh` — Verify all phases complete
- `scripts/session-catchup.py`: Explicit same-project session-record aggregation or bounded replay (`--metadata` / `--replay`); bare invocation does not access host history
### List saved plans
To find a task before resuming it, run `sh "<skill-dir>/scripts/set-active-plan.sh" --list` or, in Windows PowerShell, `& "<skill-dir>/scripts/set-active-plan.ps1" -List`. Replace `<skill-dir>` with this installed skill directory and keep your current directory at the project root.
This read-only command lists named plans and phase progress under the current directory's `.planning/`. `[active]` marks the shared default pointer; it does not bind a session. Concurrent tasks still require each host's `PLAN_ID` or separate worktrees.
## Advanced Topics
- **Manus Principles:** See [reference.md](reference.md)
+266 -16
View File
@@ -1,27 +1,245 @@
# planning-with-files: set or display the active plan pointer (PowerShell).
# planning-with-files: set, display, or list the active plan pointer (PowerShell).
#
# Usage:
# .\set-active-plan.ps1 <plan_id> - pin .planning\.active_plan to plan_id
# .\set-active-plan.ps1 - print the current active plan (if any)
# .\set-active-plan.ps1 -List - list available named plans and phase counts
# .\set-active-plan.ps1 --list - equivalent to -List and -l
param(
[string]$PlanId = ""
[Parameter(Position = 0)]
[string]$PlanId = "",
[Alias('l', '-list')]
[switch]$List,
[Alias('h', '-help')]
[switch]$Help
)
$PlanRoot = Join-Path (Get-Location) ".planning"
$ProjectRoot = (Get-Location).Path
# Windows PowerShell 5.1 can silently change directories when -File inherits
# a cwd containing wildcard characters. Recover that physical cwd only for a
# direct invocation of this script; interactive Set-Location remains primary.
if ($PSVersionTable.PSVersion.Major -eq 5 -and
[System.Management.Automation.WildcardPattern]::ContainsWildcardCharacters([Environment]::CurrentDirectory)) {
$processArgs = [Environment]::GetCommandLineArgs()
for ($index = 0; $index -lt ($processArgs.Length - 1); $index++) {
if ($processArgs[$index] -ieq '-File') {
$entryScript = $processArgs[$index + 1]
if (-not [IO.Path]::IsPathRooted($entryScript)) {
$entryScript = Join-Path ([Environment]::CurrentDirectory) $entryScript
}
if ([IO.Path]::GetFullPath($entryScript) -eq [IO.Path]::GetFullPath($PSCommandPath)) {
$ProjectRoot = [Environment]::CurrentDirectory
}
break
}
}
}
$PlanRoot = Join-Path $ProjectRoot ".planning"
$ActiveFile = Join-Path $PlanRoot ".active_plan"
$script:IsWindowsHost = [Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT
# Resolve-Path can retain a junction's spelling. Like resolve-plan-dir.ps1,
# open the actual filesystem object before checking the project boundary.
# The Windows handle supports both directories and task_plan.md files.
function Get-FinalPath {
param([string]$Path)
if (-not ("PwfPlanListingNative" -as [type])) {
Add-Type -ErrorAction Stop -TypeDefinition @'
using System;
using System.ComponentModel;
using System.Runtime.InteropServices;
using System.Text;
using Microsoft.Win32.SafeHandles;
public static class PwfPlanListingNative {
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern SafeFileHandle CreateFileW(
string name, uint access, uint share, IntPtr security,
uint creation, uint flags, IntPtr template);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern uint GetFinalPathNameByHandleW(
SafeFileHandle handle, StringBuilder path, uint length, uint flags);
[DllImport("libc", SetLastError = true)]
private static extern IntPtr realpath(string path, IntPtr resolved);
[DllImport("libc")]
private static extern void free(IntPtr pointer);
public static string FinalPath(string path) {
if (Environment.OSVersion.Platform != PlatformID.Win32NT) {
IntPtr resolved = realpath(path, IntPtr.Zero);
if (resolved == IntPtr.Zero) throw new Win32Exception(Marshal.GetLastWin32Error());
try { return Marshal.PtrToStringAnsi(resolved); }
finally { free(resolved); }
}
// OPEN_EXISTING, FILE_SHARE_READ | WRITE | DELETE, BACKUP_SEMANTICS.
using (SafeFileHandle handle = CreateFileW(
path, 0, 7, IntPtr.Zero, 3, 0x02000000, IntPtr.Zero)) {
if (handle.IsInvalid) throw new Win32Exception(Marshal.GetLastWin32Error());
StringBuilder buffer = new StringBuilder(32768);
uint length = GetFinalPathNameByHandleW(handle, buffer, (uint)buffer.Capacity, 0);
if (length == 0 || length >= buffer.Capacity)
throw new Win32Exception(Marshal.GetLastWin32Error());
string result = buffer.ToString();
if (result.StartsWith(@"\\?\UNC\", StringComparison.OrdinalIgnoreCase))
return @"\\" + result.Substring(8);
if (result.StartsWith(@"\\?\", StringComparison.OrdinalIgnoreCase))
return result.Substring(4);
return result;
}
}
}
'@
}
return [PwfPlanListingNative]::FinalPath($Path)
}
function Test-WithinRoot {
param([string]$Path)
try {
$rootReal = (Get-FinalPath $ProjectRoot).TrimEnd('\', '/')
$pathReal = (Get-FinalPath $Path).TrimEnd('\', '/')
$comparison = [StringComparison]::Ordinal
if ($script:IsWindowsHost) { $comparison = [StringComparison]::OrdinalIgnoreCase }
return $pathReal.Equals($rootReal, $comparison) -or
$pathReal.StartsWith($rootReal + [IO.Path]::DirectorySeparatorChar, $comparison)
} catch {
return $false
}
}
function Test-ValidSlug {
param([string]$Name)
return $Name -cmatch '^[A-Za-z0-9_][A-Za-z0-9._-]*\z'
}
function Test-SafeActiveFile {
param([switch]$AllowLink)
$item = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
if (-not $item) { return $false }
# Reading may follow a verified in-project link; writing must not.
return -not $item.PSIsContainer -and
($AllowLink -or (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0)) -and
(Test-WithinRoot $ActiveFile)
}
function Get-CurrentActivePlan {
if (-not (Test-SafeActiveFile -AllowLink)) { return "" }
try {
$current = ([string](Get-Content -LiteralPath $ActiveFile -Raw -Encoding UTF8 -ErrorAction Stop)).Trim([char[]]"`r`n")
if (Test-ValidSlug $current) { return $current }
} catch { }
return ""
}
function Add-PhaseStatus {
param([hashtable]$Counts, $Phase)
if ($null -eq $Phase) { return }
$status = $Phase.Inline
if ($Phase.Primary) { $status = $Phase.Primary }
if ($status) { $Counts[$status]++ }
}
function Get-PhaseStatus {
param([string]$PlanFile)
$counts = @{ total = 0; complete = 0; in_progress = 0; pending = 0 }
$phase = $null
$fence = ""
$fenceLength = 0
# The shipped translations keep status identifiers in English. Unicode
# escapes preserve Windows PowerShell 5.1 compatibility without a BOM.
$phaseLabel = '(?:Phase|Fase|\u0627\u0644\u0645\u0631\u062d\u0644\u0629|\u9636\u6bb5|\u968e\u6bb5)'
$statusLabel = '(?:Status:|Estado:|\u0627\u0644\u062d\u0627\u0644\u0629:|\u72b6\u6001\uff1a|\u72c0\u614b\uff1a)'
# Read once; an empty file is a valid plan with zero phases.
$content = [string](Get-Content -LiteralPath $PlanFile -Raw -Encoding UTF8 -ErrorAction Stop)
foreach ($line in ($content -split '\r?\n')) {
if ($fence) {
$closing = '^ {0,3}' + [regex]::Escape($fence) + '{' + $fenceLength + ',}[ \t]*$'
if ($line -match $closing) { $fence = "" }
continue
}
if ($line -match '^ {0,3}(`{3,}|~{3,})') {
$fence = $Matches[1].Substring(0, 1)
$fenceLength = $Matches[1].Length
continue
}
if ($line -cmatch ('^ {0,3}###[ \t]+' + $phaseLabel + '[ \t]+[0-9]+(?:[^0-9A-Za-z_]|$)')) {
Add-PhaseStatus $counts $phase
$phase = @{ Primary = ""; Inline = "" }
$counts.total++
if ($line -cmatch '\[(complete|in_progress|pending)\]') {
$phase.Inline = $Matches[1]
}
continue
}
if ($line -match '^ {0,3}#{1,3}(?:[ \t]+|$)') {
Add-PhaseStatus $counts $phase
$phase = $null
continue
}
if ($null -ne $phase -and -not $phase.Primary -and
$line -cmatch ('^ {0,3}(?:-[ \t]+)?\*\*' + $statusLabel + '\*\*[ \t]+(complete|in_progress|pending)(?:[ \t]|$)')) {
$phase.Primary = $Matches[1]
}
}
Add-PhaseStatus $counts $phase
return "$($counts.complete)/$($counts.total) complete, $($counts.in_progress) in_progress, $($counts.pending) pending"
}
function Show-PlanList {
if (-not (Test-Path -LiteralPath $PlanRoot -PathType Container)) {
Write-Output "No planning directory found."
return
}
if (-not (Test-WithinRoot $PlanRoot)) {
Write-Error "Error: planning directory is outside the project or cannot be verified."
exit 1
}
$active = Get-CurrentActivePlan
Write-Output "Available plans:"
Write-Output "[active] marks the shared .active_plan pointer; listing does not bind this session."
$found = $false
foreach ($plan in (Get-ChildItem -LiteralPath $PlanRoot -Directory -ErrorAction SilentlyContinue | Sort-Object Name)) {
if (-not (Test-ValidSlug $plan.Name) -or -not (Test-WithinRoot $plan.FullName)) { continue }
$planFile = Join-Path $plan.FullName "task_plan.md"
if (-not (Test-Path -LiteralPath $planFile -PathType Leaf) -or -not (Test-WithinRoot $planFile)) { continue }
try { $status = Get-PhaseStatus $planFile } catch { continue }
$marker = ""
if ($plan.Name -ceq $active) { $marker = " [active]" }
Write-Output "- $($plan.Name)$marker - $status"
$found = $true
}
if (-not $found) { Write-Output "No named plans found." }
}
if ($Help -or $PlanId -eq "--help" -or $PlanId -eq "-h") {
Write-Output "Usage: set-active-plan.ps1 [-List|-l|--list|PLAN_ID]"
exit 0
}
if ($List -or $PlanId -eq "--list" -or $PlanId -eq "-l") {
if ($List -and $PlanId) {
Write-Error "Error: list plans or set PLAN_ID in separate calls."
exit 1
}
Show-PlanList
exit 0
}
if ($PlanId -eq "") {
if (Test-Path $ActiveFile) {
$current = (Get-Content $ActiveFile -Raw -Encoding UTF8).Trim()
$current = ""
if ((Test-Path -LiteralPath $PlanRoot -PathType Container) -and -not (Test-WithinRoot $PlanRoot)) {
Write-Error "Error: planning directory is outside the project or cannot be verified."
exit 1
}
if (Test-WithinRoot $PlanRoot) { $current = Get-CurrentActivePlan }
if ($current) {
$planDir = Join-Path $PlanRoot $current
if ($current -ne "" -and (Test-Path $planDir)) {
if ((Test-Path -LiteralPath $planDir -PathType Container) -and (Test-WithinRoot $planDir)) {
Write-Output "Active plan: $current"
Write-Output "Path: $planDir"
} elseif ($current -ne "") {
Write-Output "Active plan pointer: $current (directory not found - stale pointer)"
} else {
Write-Output "No active plan set."
Write-Output "Active plan pointer: $current (directory not found or outside project - stale pointer)"
}
} else {
Write-Output "No active plan set."
@@ -29,21 +247,53 @@ if ($PlanId -eq "") {
exit 0
}
if (-not (Test-ValidSlug $PlanId)) {
Write-Error "Error: invalid plan ID. Use letters, numbers, underscores, dots, or hyphens; start with a letter, number, or underscore."
exit 1
}
$PlanDir = Join-Path $PlanRoot $PlanId
if (-not (Test-Path $PlanDir)) {
if (-not (Test-Path -LiteralPath $PlanDir -PathType Container)) {
Write-Error "Error: plan directory not found: $PlanDir"
Write-Error "Run: init-session.sh `"$PlanId`" to create it, or check .planning\ for available plans."
exit 1
}
if (-not (Test-Path $PlanRoot)) {
New-Item -ItemType Directory -Path $PlanRoot -Force | Out-Null
if (-not (Test-WithinRoot $PlanRoot) -or -not (Test-WithinRoot $PlanDir)) {
Write-Error "Error: plan directory must remain within the project."
exit 1
}
$activeItem = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
if ($activeItem -and -not (Test-SafeActiveFile)) {
Write-Error "Error: the active plan pointer must be a regular file within the project."
exit 1
}
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($ActiveFile, $PlanId, $utf8NoBom)
# Replace the directory entry instead of truncating an existing inode: a
# hardlinked pointer must not overwrite another file. The exclusive temporary
# file lives beside the pointer, keeping replacement on the same filesystem.
$tempFile = Join-Path $PlanRoot ('.active_plan.' + [guid]::NewGuid().ToString('N') + '.tmp')
$createdTemp = $false
try {
$stream = [IO.File]::Open($tempFile, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
$createdTemp = $true
try {
$bytes = $utf8NoBom.GetBytes($PlanId)
$stream.Write($bytes, 0, $bytes.Length)
$stream.Flush()
} finally { $stream.Dispose() }
if ($activeItem) {
[IO.File]::Replace($tempFile, $ActiveFile, [NullString]::Value)
} else {
[IO.File]::Move($tempFile, $ActiveFile)
}
} catch {
Write-Error "Error: could not set the active plan pointer: $($_.Exception.Message)"
exit 1
} finally {
if ($createdTemp -and (Test-Path -LiteralPath $tempFile)) {
Remove-Item -LiteralPath $tempFile -Force -ErrorAction SilentlyContinue
}
}
Write-Output "Active plan set to: $PlanId"
Write-Output "Path: $PlanDir"
Write-Output ""
+323 -38
View File
@@ -1,50 +1,335 @@
#!/bin/sh
# planning-with-files: set or display the active plan pointer.
#
# Usage:
# set-active-plan.sh <plan_id> — pin .planning/.active_plan to plan_id
# set-active-plan.sh — print the current active plan (if any)
#
# The active plan is stored in .planning/.active_plan and is read by
# resolve-plan-dir.sh when no $PLAN_ID env var is set.
set -e
# List saved named plans, show the shared pointer, or change that pointer.
# Usage: set-active-plan.sh [--list|-l|PLAN_ID]
# Operates on the current project; listing never binds a host or injects data.
set -eu
PLAN_ROOT="${PWD}/.planning"
ACTIVE_FILE="${PLAN_ROOT}/.active_plan"
PWF_ROOT_PIN=""
# No args → show current active plan
if [ "${1:-}" = "" ]; then
if [ -f "${ACTIVE_FILE}" ]; then
plan_id="$(tr -d '\r\n' < "${ACTIVE_FILE}")"
if [ -n "${plan_id}" ] && [ -d "${PLAN_ROOT}/${plan_id}" ]; then
echo "Active plan: ${plan_id}"
echo "Path: ${PLAN_ROOT}/${plan_id}"
elif [ -n "${plan_id}" ]; then
echo "Active plan pointer: ${plan_id} (directory not found — stale pointer)"
else
echo "No active plan set."
fi
# Use the resolver canonicalization policy without running plan selection.
slug_is_valid() {
case "$1" in
'') return 1 ;;
*[!A-Za-z0-9._-]*) return 1 ;;
[A-Za-z0-9_]*) return 0 ;;
esac
return 1
}
# Pure-sh backslash-to-forward-slash normalizer; result lands in $NORM_OUT.
# Windows-native coreutils builds (e.g. C:\Program Files\coreutils on PATH
# ahead of Git's usr/bin) canonicalize MSYS-style /c/... input to C:\-style
# backslash output. The containment prefix match below is written with forward
# slashes, so without this normalization every canonical pair mismatches and
# resolution silently fails. On POSIX systems paths contain no backslash and
# this is the identity. A literal backslash in a Unix filename normalizes to
# "/" and at worst fails containment — the safe direction. No subshell, no
# fork: plain parameter expansion in a loop.
norm_slashes() {
NORM_OUT=""
_ns_rest="$1"
while :; do
case "${_ns_rest}" in
*\\*)
NORM_OUT="${NORM_OUT}${_ns_rest%%\\*}/"
_ns_rest="${_ns_rest#*\\}"
;;
*)
NORM_OUT="${NORM_OUT}${_ns_rest}"
break
;;
esac
done
}
# Return true when a candidate path names the Microsoft Store WindowsApps
# directory. Store app aliases are not stable interpreter binaries and may
# present as executable while refusing script execution. Matching is
# case-insensitive and works before or after Windows slash normalization.
is_windowsapps_path() {
norm_slashes "$1"
case "${NORM_OUT}" in
[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]|\
[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]/*|\
*/[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]|\
*/[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]/*) return 0 ;;
esac
return 1
}
# Select only an interpreter path the caller explicitly trusted.
# PWF_TRUSTED_PYTHON is preferred; PYTHON_BIN remains a compatibility alias.
# PATH discovery is intentionally forbidden because resolver hooks can run in
# repositories that control PATH. Windows-native absolute paths are converted
# with Git Bash's fixed system cygpath, never a PATH-selected shim.
trusted_python() {
for _tp_candidate in "${PWF_TRUSTED_PYTHON:-}" "${PYTHON_BIN:-}"; do
[ -n "${_tp_candidate}" ] || continue
case "${_tp_candidate}" in
\\\\*|//*) continue ;;
[A-Za-z]:[\\/]*)
is_windowsapps_path "${_tp_candidate}" && continue
_tp_cygpath="/usr/bin/cygpath.exe"
[ -f "${_tp_cygpath}" ] && [ -x "${_tp_cygpath}" ] || continue
_tp_candidate="$("${_tp_cygpath}" -u "${_tp_candidate}" 2>/dev/null)" \
|| continue
;;
/*) ;;
*) continue ;;
esac
is_windowsapps_path "${_tp_candidate}" && continue
[ -f "${_tp_candidate}" ] || continue
[ -x "${_tp_candidate}" ] || continue
printf "%s\n" "${_tp_candidate}"
return 0
done
return 1
}
# Portable path canonicalizer. realpath first (Linux, modern coreutils),
# then readlink -f (older GNU), then an explicitly trusted Python interpreter.
# Prints the canonical absolute path on success; prints nothing and returns 1
# on a full miss so containment fails closed. No Python spawn on the happy
# path: realpath/readlink cover Linux, WSL, Git-Bash, and modern macOS.
canonicalize() {
target="$1"
if command -v realpath >/dev/null 2>&1; then
out="$(realpath "${target}" 2>/dev/null)" && [ -n "${out}" ] && {
printf "%s\n" "${out}"; return 0; }
fi
if command -v readlink >/dev/null 2>&1; then
out="$(readlink -f "${target}" 2>/dev/null)" && [ -n "${out}" ] && {
printf "%s\n" "${out}"; return 0; }
fi
_canonical_python="$(trusted_python)" || _canonical_python=""
if [ -n "${_canonical_python}" ]; then
out="$("${_canonical_python}" -I -c "import os,sys;print(os.path.realpath(sys.argv[1]))" "${target}" 2>/dev/null)" \
&& [ -n "${out}" ] && { printf "%s\n" "${out}"; return 0; }
fi
return 1
}
# Containment guard (security A1.3): a resolved plan dir must canonicalize to a
# path under the project root (the CWD the script runs from). A symlink inside
# a valid slug dir pointing at /etc or outside the workspace would otherwise let
# the hooks hash and inject an arbitrary file. On any violation we return 1 so
# the caller treats the candidate as unresolved and falls back safely.
#
# The root canonicalizes via the relative token "." rather than the $PWD
# string. On some Windows/MSYS setups (8.3 short names, the /tmp mount alias)
# realpath("$PWD") and realpath(relative-candidate) resolve through different
# code paths and land on differently-spelled-but-equal targets, so the prefix
# match below fails and resolution silently goes dark. "." resolves through
# the same physical-cwd path candidates already use (same fix inject-plan.sh
# received earlier; the resolver kept the $PWD form until now). Both sides are
# backslash-normalized before comparison for Windows-native canonicalizers.
# The root is computed once per run: the newest-mtime scan calls this guard
# per plan dir, and each canonicalize costs a process spawn on Windows.
#
# With a PWF_PLAN_ROOT pin (issue #212) containment is checked against THAT
# root instead of the cwd: candidates arrive ${PWF_PLAN_ROOT}/-prefixed, so
# both sides canonicalize through the same path spelling. Unpinned keeps the
# relative "." root — byte-identical to the legacy check.
ROOT_REAL=""
ROOT_REAL_SET=0
is_within_root() {
candidate="$1"
if [ "${ROOT_REAL_SET}" = "0" ]; then
ROOT_REAL="$(canonicalize "${PWF_ROOT_PIN:-.}")" || ROOT_REAL=""
norm_slashes "${ROOT_REAL}"
ROOT_REAL="${NORM_OUT}"
ROOT_REAL_SET=1
fi
# Canonicalize the candidate through its cwd-RELATIVE form whenever it
# lives under ${PWD}. The candidate string is built from ${PWD} (an MSYS
# long-form spelling), while the root canonicalizes from "." (the process
# cwd, which a caller may have set with an 8.3 short-form string). A
# Windows-native realpath does not unify those spellings, so canonicalizing
# both sides from the same cwd base is the only spelling-stable comparison.
# The emitted result keeps the original absolute candidate — only the
# containment check uses the relative form.
# Pinned resolution skips the rewrite: candidate and root then share the
# ${PWF_PLAN_ROOT} spelling, so both canonicalize directly from it.
if [ -n "${PWF_ROOT_PIN}" ]; then
check_target="${candidate}"
else
echo "No active plan set."
case "${candidate}" in
"${PWD}"/*) check_target=".${candidate#"${PWD}"}" ;;
*) check_target="${candidate}" ;;
esac
fi
cand_real="$(canonicalize "${check_target}")" || cand_real=""
norm_slashes "${cand_real}"
cand_real="${NORM_OUT}"
if [ -z "${ROOT_REAL}" ] || [ -z "${cand_real}" ]; then
# Slug validation blocks textual traversal, but only successful
# canonicalization can rule out a symlink/junction escape.
return 1
fi
case "${cand_real}" in
"${ROOT_REAL}"|"${ROOT_REAL}"/*) return 0 ;;
*) return 1 ;;
esac
}
# Each phase contributes at most one status. An explicit Status line wins
# over an inline heading marker. Fenced examples and unrelated sections are
# not phases. Keep this parser aligned with the PowerShell helper.
phase_status() {
awk '
function finish() {
if (phase) {
status = primary != "" ? primary : inline_status
if (status == "complete") complete++
else if (status == "in_progress") in_progress++
else if (status == "pending") pending++
}
phase = 0; primary = ""; inline_status = ""
}
{
line = $0
sub(/\r$/, "", line)
trimmed = line
sub(/^ */, "", trimmed)
if (line ~ /^ ? ? ?```/ || line ~ /^ ? ? ?~~~/) {
marker = substr(trimmed, 1, 1)
run = 0
while (substr(trimmed, run + 1, 1) == marker) run++
if (fence == "") { fence = marker; fence_length = run }
else if (marker == fence && run >= fence_length && substr(trimmed, run + 1) ~ /^[ \t]*$/) fence = ""
next
}
if (fence != "") next
if (line ~ /^ ? ? ?###[ \t]+(Phase|Fase|المرحلة|阶段|階段)[ \t]+[0-9]+([^0-9A-Za-z_]|$)/) {
finish(); phase = 1; total++
if (match(line, /\[(complete|in_progress|pending)\]/))
inline_status = substr(line, RSTART + 1, RLENGTH - 2)
} else if (line ~ /^ ? ? ?(###|##|#)([ \t]|$)/) {
finish()
} else if (phase && primary == "" && line ~ /^ ? ? ?(-[ \t]+)?\*\*(Status:|Estado:|الحالة:|状态:|狀態:)\*\*[ \t]+(complete|in_progress|pending)([ \t]|$)/) {
sub(/^ ? ? ?(-[ \t]+)?\*\*(Status:|Estado:|الحالة:|状态:|狀態:)\*\*[ \t]+/, "", line)
sub(/[ \t].*$/, "", line)
primary = line
}
}
END {
finish()
printf "%d/%d complete, %d in_progress, %d pending", complete, total, in_progress, pending
}
' < "$1"
}
current_active() {
if [ -f "${ACTIVE_FILE}" ] && is_within_root "${ACTIVE_FILE}"; then
_current="$(tr '\r' '\n' < "${ACTIVE_FILE}")"
# Windows editors and older PowerShell defaults can leave a UTF-8 BOM.
# Treat it as an encoding marker, not part of the shared plan slug.
_utf8_bom="$(printf '\357\273\277')"
case "${_current}" in
"${_utf8_bom}"*) _current="${_current#"${_utf8_bom}"}" ;;
esac
slug_is_valid "${_current}" && printf '%s\n' "${_current}"
fi
return 0
}
list_plans() {
if [ ! -d "${PLAN_ROOT}" ]; then
printf '%s\n' 'No planning directory found.'
return 0
fi
if ! is_within_root "${PLAN_ROOT}"; then
printf '%s\n' 'Error: planning directory is outside the project or cannot be verified.' >&2
return 1
fi
_active="$(current_active)"
_found=0
printf '%s\n' 'Available plans:'
for _dir in "${PLAN_ROOT}"/*; do
[ -d "${_dir}" ] || continue
_id="${_dir##*/}"
slug_is_valid "${_id}" || continue
is_within_root "${_dir}" || continue
_plan_file="${_dir}/task_plan.md"
[ -f "${_plan_file}" ] && [ -r "${_plan_file}" ] || continue
is_within_root "${_plan_file}" || continue
_status="$(phase_status "${_plan_file}")" || continue
_marker=''
if [ "${_id}" = "${_active}" ]; then _marker=' [active]'; fi
printf '%s\n' "- ${_id}${_marker} - ${_status}"
_found=1
done
if [ "${_found}" -eq 0 ]; then
printf '%s\n' 'No named plans found.'
else
printf '%s\n' '[active] marks the shared default pointer. Set PLAN_ID to pin a session.'
fi
}
if [ "$#" -gt 1 ]; then
printf '%s\n' 'Error: list plans or set PLAN_ID in separate calls.' >&2
exit 1
fi
case "${1:-}" in
--list|-l) list_plans; exit $? ;;
--help|-h)
printf '%s\n' 'Usage: set-active-plan.sh [--list|PLAN_ID]' \
'Lists saved named plans in the current project without selecting a plan.'
exit 0 ;;
esac
if [ "${1:-}" = '' ]; then
if [ -d "${PLAN_ROOT}" ] && ! is_within_root "${PLAN_ROOT}"; then
printf '%s\n' 'Error: planning directory is outside the project or cannot be verified.' >&2
exit 1
fi
plan_id="$(current_active)"
if [ -n "${plan_id}" ] && [ -d "${PLAN_ROOT}/${plan_id}" ] && is_within_root "${PLAN_ROOT}/${plan_id}"; then
printf '%s\n' "Active plan: ${plan_id}" "Path: ${PLAN_ROOT}/${plan_id}"
elif [ -n "${plan_id}" ]; then
printf '%s\n' "Active plan pointer: ${plan_id} (directory not found or outside project - stale pointer)"
else
printf '%s\n' 'No active plan set.'
fi
exit 0
fi
PLAN_ID="$1"
PLAN_DIR="${PLAN_ROOT}/${PLAN_ID}"
if [ ! -d "${PLAN_DIR}" ]; then
echo "Error: plan directory not found: ${PLAN_DIR}" >&2
echo "Run: init-session.sh \"${PLAN_ID}\" to create it, or check .planning/ for available plans." >&2
if ! slug_is_valid "${PLAN_ID}"; then
printf '%s\n' 'Error: invalid plan ID. Use a named directory under .planning.' >&2
exit 1
fi
mkdir -p "${PLAN_ROOT}"
printf "%s\n" "${PLAN_ID}" > "${ACTIVE_FILE}"
echo "Active plan set to: ${PLAN_ID}"
echo "Path: ${PLAN_DIR}"
echo ""
echo "To pin this terminal session only:"
echo " export PLAN_ID=${PLAN_ID}"
PLAN_DIR="${PLAN_ROOT}/${PLAN_ID}"
if [ ! -d "${PLAN_DIR}" ]; then
printf '%s\n' "Error: plan directory not found: ${PLAN_DIR}" \
"Run: init-session.sh \"${PLAN_ID}\" to create it, or use --list to see available plans." >&2
exit 1
fi
if ! is_within_root "${PLAN_ROOT}" || ! is_within_root "${PLAN_DIR}"; then
printf '%s\n' 'Error: plan directory is outside the project or cannot be verified.' >&2
exit 1
fi
# A pre-existing pointer must itself be a contained regular file before writing.
if { [ -e "${ACTIVE_FILE}" ] || [ -L "${ACTIVE_FILE}" ]; } &&
{ [ -L "${ACTIVE_FILE}" ] || [ ! -f "${ACTIVE_FILE}" ] || ! is_within_root "${ACTIVE_FILE}"; }; then
printf '%s\n' 'Error: active plan pointer is not a safe file inside the project.' >&2
exit 1
fi
# Replace the pointer atomically instead of truncating a possible hardlink.
# mktemp creates a private, exclusive file beside the destination.
temp_file="$(mktemp "${PLAN_ROOT}/.active_plan.XXXXXX")" || {
printf '%s\n' 'Error: could not create the active plan pointer.' >&2
exit 1
}
trap 'rm -f "${temp_file}"' EXIT
trap 'exit 1' HUP INT TERM
printf '%s\n' "${PLAN_ID}" > "${temp_file}"
if ! mv -f "${temp_file}" "${ACTIVE_FILE}"; then
printf '%s\n' 'Error: could not replace the active plan pointer.' >&2
exit 1
fi
trap - EXIT HUP INT TERM
printf '%s\n' "Active plan set to: ${PLAN_ID}" "Path: ${PLAN_DIR}" '' \
'To pin this terminal session only:' " export PLAN_ID=${PLAN_ID}"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "planning-with-files",
"version": "3.17.2",
"version": "3.18.2",
"description": "Persistent project planning for Codex. Opt-in lifecycle hooks inject selected plan context; automatic recovery uses project files only. Explicit catchup modes may read same-project local session records to emit aggregate counts or bounded nonce-framed excerpts. Optional gated mode can request continuation on capable hosts and never runs Markdown-declared commands. No network upload path.",
"author": {
"name": "OthmanAdi",
+2 -2
View File
@@ -37,10 +37,10 @@ if [ -f "$PLAN_FILE" ]; then
if [ -n "$PWF_PYTHON" ] && [ -f "$PWF_PYTHON" ] && [ -f "${HOOK_DIR}/context_frame.py" ]; then
if [ -n "$PLAN_DIR" ]; then ATTEST_FILE="${PLAN_DIR}/.attestation"; else ATTEST_FILE=".plan-attestation"; fi
if [ -n "$PLAN_DIR" ]; then MODE_FILE="${PLAN_DIR}/.mode"; else MODE_FILE=".mode"; fi
PWF_MODE=$("$PWF_PYTHON" -c 'import sys; from pathlib import Path; sys.path.insert(0, sys.argv[1]); import context_frame as c; raw=c.read_optional_regular_bytes(Path(sys.argv[2]), max_source_bytes=256); tokens=[] if raw is None else raw.decode("ascii", errors="strict").split(); allowed={"autonomous", "gate", "inject-smart"}; print("unsafe" if any(t not in allowed for t in tokens) else ("gated" if "gate" in tokens else ("autonomous" if "autonomous" in tokens else "legacy")))' "$HOOK_DIR" "$MODE_FILE" 2>/dev/null) || PWF_MODE="unsafe"
PWF_MODE=$("$PWF_PYTHON" -I -X utf8 -c 'import sys; from pathlib import Path; sys.path.insert(0, sys.argv[1]); import context_frame as c; raw=c.read_optional_regular_bytes(Path(sys.argv[2]), max_source_bytes=256); tokens=[] if raw is None else raw.decode("ascii", errors="strict").split(); allowed={"autonomous", "gate", "inject-smart"}; print("unsafe" if any(t not in allowed for t in tokens) else ("gated" if "gate" in tokens else ("autonomous" if "autonomous" in tokens else "legacy")))' "$HOOK_DIR" "$MODE_FILE" 2>/dev/null) || PWF_MODE="unsafe"
case "$PWF_MODE" in
autonomous|gated) echo '{"decision": "allow"}'; exit 0 ;;
legacy) "$PWF_PYTHON" "${HOOK_DIR}/context_frame.py" plan "$PLAN_FILE" --head 30 --attestation "$ATTEST_FILE" --mode "$MODE_FILE" >&2 ;;
legacy) "$PWF_PYTHON" -I -X utf8 "${HOOK_DIR}/context_frame.py" plan "$PLAN_FILE" --head 30 --attestation "$ATTEST_FILE" --mode "$MODE_FILE" >&2 ;;
*) : ;;
esac
fi
+1 -1
View File
@@ -12,7 +12,7 @@ SKILL_DIR="$CODEX_ROOT/skills/planning-with-files"
PYTHON_BIN="${PYTHON_BIN:-$(command -v python3 || command -v python)}"
if [ -n "$PYTHON_BIN" ] && [ -f "$SKILL_DIR/scripts/session-catchup.py" ]; then
"$PYTHON_BIN" "$SKILL_DIR/scripts/session-catchup.py" --no-history "$(pwd)"
"$PYTHON_BIN" -I -X utf8 "$SKILL_DIR/scripts/session-catchup.py" --no-history "$(pwd)"
fi
sh "$SCRIPT_DIR/user-prompt-submit.sh"
+5 -5
View File
@@ -23,7 +23,7 @@ validate_python_candidate() {
case "$_tp_lower" in
*/microsoft/windowsapps/*) return 1 ;;
esac
"$_tp_candidate" -c 'import sys' >/dev/null 2>&1 || return 1
"$_tp_candidate" -I -X utf8 -c 'import sys' >/dev/null 2>&1 || return 1
printf '%s\n' "$_tp_candidate"
}
@@ -61,7 +61,7 @@ if [ -n "${PWF_PLAN_ROOT:-}" ]; then
esac
PIN_PYTHON="$(trusted_python explicit 2>/dev/null)" || PIN_PYTHON=""
[ -n "$PIN_PYTHON" ] || { echo "[planning-with-files] PWF_PLAN_ROOT could not be validated; nothing injected."; exit 0; }
PIN_REAL=$("$PIN_PYTHON" -c 'import sys; from pathlib import Path; sys.path.insert(0, sys.argv[1]); import codex_hook_adapter as a; root=a.effective_plan_root(Path.cwd()); print(root or "")' "$HOOK_DIR" 2>/dev/null) || PIN_REAL=""
PIN_REAL=$("$PIN_PYTHON" -I -X utf8 -c 'import sys; from pathlib import Path; sys.path.insert(0, sys.argv[1]); import codex_hook_adapter as a; root=a.effective_plan_root(Path.cwd()); print(root or "")' "$HOOK_DIR" 2>/dev/null) || PIN_REAL=""
[ -n "$PIN_REAL" ] || { echo "[planning-with-files] PWF_PLAN_ROOT must stay within the current workspace; nothing injected."; exit 0; }
if [ -n "$PIN_REAL" ] && [ -d "$PIN_REAL" ]; then
PWF_PLAN_ROOT="$PIN_REAL"
@@ -145,7 +145,7 @@ else
if [ -z "$PWF_PYTHON" ]; then
PWF_SESSION_ADMISSION="refused"
else
PWF_SESSION_ADMISSION=$("$PWF_PYTHON" -c 'import sys; from pathlib import Path; sys.path.insert(0, sys.argv[1]); import codex_hook_adapter as a; root=a.effective_plan_root(Path.cwd());
PWF_SESSION_ADMISSION=$("$PWF_PYTHON" -I -X utf8 -c 'import sys; from pathlib import Path; sys.path.insert(0, sys.argv[1]); import codex_hook_adapter as a; root=a.effective_plan_root(Path.cwd());
if root is None: print("refused")
else:
try: (root / ".planning" / "sessions").lstat(); armed=True
@@ -253,13 +253,13 @@ if [ -f "$PLAN_FILE" ]; then
if [ -z "$PWF_PYTHON" ] || [ ! -f "${HOOK_DIR}/context_frame.py" ]; then exit 0; fi
if [ -n "$PLAN_DIR" ]; then ATTEST_FILE="${PLAN_DIR}/.attestation"; else ATTEST_FILE="${PLAN_PREFIX}.plan-attestation"; fi
if [ -n "$PLAN_DIR" ]; then MODE_FILE="${PLAN_DIR}/.mode"; else MODE_FILE="${PLAN_PREFIX}.mode"; fi
PLAN_CONTEXT=$("$PWF_PYTHON" "${HOOK_DIR}/context_frame.py" plan "$PLAN_FILE" --head 50 --attestation "$ATTEST_FILE" --mode "$MODE_FILE" 2>&1)
PLAN_CONTEXT=$("$PWF_PYTHON" -I -X utf8 "${HOOK_DIR}/context_frame.py" plan "$PLAN_FILE" --head 50 --attestation "$ATTEST_FILE" --mode "$MODE_FILE" 2>&1)
if [ $? -ne 0 ]; then printf '%s\n' "$PLAN_CONTEXT"; exit 0; fi
echo "[planning-with-files] ACTIVE PLAN — current state:"
printf '%s\n' "$PLAN_CONTEXT"
echo ""
if [ -f "$PROGRESS_FILE" ]; then
PROGRESS_CONTEXT=$("$PWF_PYTHON" "${HOOK_DIR}/context_frame.py" progress "$PROGRESS_FILE" --tail 20 2>&1)
PROGRESS_CONTEXT=$("$PWF_PYTHON" -I -X utf8 "${HOOK_DIR}/context_frame.py" progress "$PROGRESS_FILE" --tail 20 2>&1)
[ $? -eq 0 ] && printf '%s\n' "$PROGRESS_CONTEXT"
fi
echo ""
+7 -1
View File
@@ -36,7 +36,7 @@ hooks:
- type: command
command: "SH=\"\"; for c in \"${PWF_SCRIPT_DIR}/skill-hook.sh\" \"${CLAUDE_SKILL_DIR}/scripts/skill-hook.sh\" \"$HOME/.codex/skills/planning-with-files/scripts/skill-hook.sh\" \"$HOME/.claude/skills/planning-with-files/scripts/skill-hook.sh\" \"$HOME/.claude/plugins/marketplaces/planning-with-files/scripts/skill-hook.sh\"; do [ -f \"$c\" ] && { SH=\"$c\"; break; }; done; [ -n \"$SH\" ] && sh \"$SH\" --event=precompact; exit 0"
metadata:
version: "3.17.2"
version: "3.18.2"
---
@@ -212,6 +212,12 @@ Helper scripts for automation:
- `scripts/check-complete.sh` — Verify all phases complete
- `scripts/session-catchup.py`: Explicit same-project session-record aggregation or bounded replay (`--metadata` / `--replay`); bare invocation does not access host history
### List saved plans
To find a task before resuming it, run `sh "<skill-dir>/scripts/set-active-plan.sh" --list` or, in Windows PowerShell, `& "<skill-dir>/scripts/set-active-plan.ps1" -List`. Replace `<skill-dir>` with this installed skill directory and keep your current directory at the project root.
This read-only command lists named plans and phase progress under the current directory's `.planning/`. `[active]` marks the shared default pointer; it does not bind a session. Concurrent tasks still require each host's `PLAN_ID` or separate worktrees.
## Advanced Topics
- **Manus Principles:** See [references/reference.md](references/reference.md)
+266 -16
View File
@@ -1,27 +1,245 @@
# planning-with-files: set or display the active plan pointer (PowerShell).
# planning-with-files: set, display, or list the active plan pointer (PowerShell).
#
# Usage:
# .\set-active-plan.ps1 <plan_id> - pin .planning\.active_plan to plan_id
# .\set-active-plan.ps1 - print the current active plan (if any)
# .\set-active-plan.ps1 -List - list available named plans and phase counts
# .\set-active-plan.ps1 --list - equivalent to -List and -l
param(
[string]$PlanId = ""
[Parameter(Position = 0)]
[string]$PlanId = "",
[Alias('l', '-list')]
[switch]$List,
[Alias('h', '-help')]
[switch]$Help
)
$PlanRoot = Join-Path (Get-Location) ".planning"
$ProjectRoot = (Get-Location).Path
# Windows PowerShell 5.1 can silently change directories when -File inherits
# a cwd containing wildcard characters. Recover that physical cwd only for a
# direct invocation of this script; interactive Set-Location remains primary.
if ($PSVersionTable.PSVersion.Major -eq 5 -and
[System.Management.Automation.WildcardPattern]::ContainsWildcardCharacters([Environment]::CurrentDirectory)) {
$processArgs = [Environment]::GetCommandLineArgs()
for ($index = 0; $index -lt ($processArgs.Length - 1); $index++) {
if ($processArgs[$index] -ieq '-File') {
$entryScript = $processArgs[$index + 1]
if (-not [IO.Path]::IsPathRooted($entryScript)) {
$entryScript = Join-Path ([Environment]::CurrentDirectory) $entryScript
}
if ([IO.Path]::GetFullPath($entryScript) -eq [IO.Path]::GetFullPath($PSCommandPath)) {
$ProjectRoot = [Environment]::CurrentDirectory
}
break
}
}
}
$PlanRoot = Join-Path $ProjectRoot ".planning"
$ActiveFile = Join-Path $PlanRoot ".active_plan"
$script:IsWindowsHost = [Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT
# Resolve-Path can retain a junction's spelling. Like resolve-plan-dir.ps1,
# open the actual filesystem object before checking the project boundary.
# The Windows handle supports both directories and task_plan.md files.
function Get-FinalPath {
param([string]$Path)
if (-not ("PwfPlanListingNative" -as [type])) {
Add-Type -ErrorAction Stop -TypeDefinition @'
using System;
using System.ComponentModel;
using System.Runtime.InteropServices;
using System.Text;
using Microsoft.Win32.SafeHandles;
public static class PwfPlanListingNative {
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern SafeFileHandle CreateFileW(
string name, uint access, uint share, IntPtr security,
uint creation, uint flags, IntPtr template);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern uint GetFinalPathNameByHandleW(
SafeFileHandle handle, StringBuilder path, uint length, uint flags);
[DllImport("libc", SetLastError = true)]
private static extern IntPtr realpath(string path, IntPtr resolved);
[DllImport("libc")]
private static extern void free(IntPtr pointer);
public static string FinalPath(string path) {
if (Environment.OSVersion.Platform != PlatformID.Win32NT) {
IntPtr resolved = realpath(path, IntPtr.Zero);
if (resolved == IntPtr.Zero) throw new Win32Exception(Marshal.GetLastWin32Error());
try { return Marshal.PtrToStringAnsi(resolved); }
finally { free(resolved); }
}
// OPEN_EXISTING, FILE_SHARE_READ | WRITE | DELETE, BACKUP_SEMANTICS.
using (SafeFileHandle handle = CreateFileW(
path, 0, 7, IntPtr.Zero, 3, 0x02000000, IntPtr.Zero)) {
if (handle.IsInvalid) throw new Win32Exception(Marshal.GetLastWin32Error());
StringBuilder buffer = new StringBuilder(32768);
uint length = GetFinalPathNameByHandleW(handle, buffer, (uint)buffer.Capacity, 0);
if (length == 0 || length >= buffer.Capacity)
throw new Win32Exception(Marshal.GetLastWin32Error());
string result = buffer.ToString();
if (result.StartsWith(@"\\?\UNC\", StringComparison.OrdinalIgnoreCase))
return @"\\" + result.Substring(8);
if (result.StartsWith(@"\\?\", StringComparison.OrdinalIgnoreCase))
return result.Substring(4);
return result;
}
}
}
'@
}
return [PwfPlanListingNative]::FinalPath($Path)
}
function Test-WithinRoot {
param([string]$Path)
try {
$rootReal = (Get-FinalPath $ProjectRoot).TrimEnd('\', '/')
$pathReal = (Get-FinalPath $Path).TrimEnd('\', '/')
$comparison = [StringComparison]::Ordinal
if ($script:IsWindowsHost) { $comparison = [StringComparison]::OrdinalIgnoreCase }
return $pathReal.Equals($rootReal, $comparison) -or
$pathReal.StartsWith($rootReal + [IO.Path]::DirectorySeparatorChar, $comparison)
} catch {
return $false
}
}
function Test-ValidSlug {
param([string]$Name)
return $Name -cmatch '^[A-Za-z0-9_][A-Za-z0-9._-]*\z'
}
function Test-SafeActiveFile {
param([switch]$AllowLink)
$item = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
if (-not $item) { return $false }
# Reading may follow a verified in-project link; writing must not.
return -not $item.PSIsContainer -and
($AllowLink -or (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0)) -and
(Test-WithinRoot $ActiveFile)
}
function Get-CurrentActivePlan {
if (-not (Test-SafeActiveFile -AllowLink)) { return "" }
try {
$current = ([string](Get-Content -LiteralPath $ActiveFile -Raw -Encoding UTF8 -ErrorAction Stop)).Trim([char[]]"`r`n")
if (Test-ValidSlug $current) { return $current }
} catch { }
return ""
}
function Add-PhaseStatus {
param([hashtable]$Counts, $Phase)
if ($null -eq $Phase) { return }
$status = $Phase.Inline
if ($Phase.Primary) { $status = $Phase.Primary }
if ($status) { $Counts[$status]++ }
}
function Get-PhaseStatus {
param([string]$PlanFile)
$counts = @{ total = 0; complete = 0; in_progress = 0; pending = 0 }
$phase = $null
$fence = ""
$fenceLength = 0
# The shipped translations keep status identifiers in English. Unicode
# escapes preserve Windows PowerShell 5.1 compatibility without a BOM.
$phaseLabel = '(?:Phase|Fase|\u0627\u0644\u0645\u0631\u062d\u0644\u0629|\u9636\u6bb5|\u968e\u6bb5)'
$statusLabel = '(?:Status:|Estado:|\u0627\u0644\u062d\u0627\u0644\u0629:|\u72b6\u6001\uff1a|\u72c0\u614b\uff1a)'
# Read once; an empty file is a valid plan with zero phases.
$content = [string](Get-Content -LiteralPath $PlanFile -Raw -Encoding UTF8 -ErrorAction Stop)
foreach ($line in ($content -split '\r?\n')) {
if ($fence) {
$closing = '^ {0,3}' + [regex]::Escape($fence) + '{' + $fenceLength + ',}[ \t]*$'
if ($line -match $closing) { $fence = "" }
continue
}
if ($line -match '^ {0,3}(`{3,}|~{3,})') {
$fence = $Matches[1].Substring(0, 1)
$fenceLength = $Matches[1].Length
continue
}
if ($line -cmatch ('^ {0,3}###[ \t]+' + $phaseLabel + '[ \t]+[0-9]+(?:[^0-9A-Za-z_]|$)')) {
Add-PhaseStatus $counts $phase
$phase = @{ Primary = ""; Inline = "" }
$counts.total++
if ($line -cmatch '\[(complete|in_progress|pending)\]') {
$phase.Inline = $Matches[1]
}
continue
}
if ($line -match '^ {0,3}#{1,3}(?:[ \t]+|$)') {
Add-PhaseStatus $counts $phase
$phase = $null
continue
}
if ($null -ne $phase -and -not $phase.Primary -and
$line -cmatch ('^ {0,3}(?:-[ \t]+)?\*\*' + $statusLabel + '\*\*[ \t]+(complete|in_progress|pending)(?:[ \t]|$)')) {
$phase.Primary = $Matches[1]
}
}
Add-PhaseStatus $counts $phase
return "$($counts.complete)/$($counts.total) complete, $($counts.in_progress) in_progress, $($counts.pending) pending"
}
function Show-PlanList {
if (-not (Test-Path -LiteralPath $PlanRoot -PathType Container)) {
Write-Output "No planning directory found."
return
}
if (-not (Test-WithinRoot $PlanRoot)) {
Write-Error "Error: planning directory is outside the project or cannot be verified."
exit 1
}
$active = Get-CurrentActivePlan
Write-Output "Available plans:"
Write-Output "[active] marks the shared .active_plan pointer; listing does not bind this session."
$found = $false
foreach ($plan in (Get-ChildItem -LiteralPath $PlanRoot -Directory -ErrorAction SilentlyContinue | Sort-Object Name)) {
if (-not (Test-ValidSlug $plan.Name) -or -not (Test-WithinRoot $plan.FullName)) { continue }
$planFile = Join-Path $plan.FullName "task_plan.md"
if (-not (Test-Path -LiteralPath $planFile -PathType Leaf) -or -not (Test-WithinRoot $planFile)) { continue }
try { $status = Get-PhaseStatus $planFile } catch { continue }
$marker = ""
if ($plan.Name -ceq $active) { $marker = " [active]" }
Write-Output "- $($plan.Name)$marker - $status"
$found = $true
}
if (-not $found) { Write-Output "No named plans found." }
}
if ($Help -or $PlanId -eq "--help" -or $PlanId -eq "-h") {
Write-Output "Usage: set-active-plan.ps1 [-List|-l|--list|PLAN_ID]"
exit 0
}
if ($List -or $PlanId -eq "--list" -or $PlanId -eq "-l") {
if ($List -and $PlanId) {
Write-Error "Error: list plans or set PLAN_ID in separate calls."
exit 1
}
Show-PlanList
exit 0
}
if ($PlanId -eq "") {
if (Test-Path $ActiveFile) {
$current = (Get-Content $ActiveFile -Raw -Encoding UTF8).Trim()
$current = ""
if ((Test-Path -LiteralPath $PlanRoot -PathType Container) -and -not (Test-WithinRoot $PlanRoot)) {
Write-Error "Error: planning directory is outside the project or cannot be verified."
exit 1
}
if (Test-WithinRoot $PlanRoot) { $current = Get-CurrentActivePlan }
if ($current) {
$planDir = Join-Path $PlanRoot $current
if ($current -ne "" -and (Test-Path $planDir)) {
if ((Test-Path -LiteralPath $planDir -PathType Container) -and (Test-WithinRoot $planDir)) {
Write-Output "Active plan: $current"
Write-Output "Path: $planDir"
} elseif ($current -ne "") {
Write-Output "Active plan pointer: $current (directory not found - stale pointer)"
} else {
Write-Output "No active plan set."
Write-Output "Active plan pointer: $current (directory not found or outside project - stale pointer)"
}
} else {
Write-Output "No active plan set."
@@ -29,21 +247,53 @@ if ($PlanId -eq "") {
exit 0
}
if (-not (Test-ValidSlug $PlanId)) {
Write-Error "Error: invalid plan ID. Use letters, numbers, underscores, dots, or hyphens; start with a letter, number, or underscore."
exit 1
}
$PlanDir = Join-Path $PlanRoot $PlanId
if (-not (Test-Path $PlanDir)) {
if (-not (Test-Path -LiteralPath $PlanDir -PathType Container)) {
Write-Error "Error: plan directory not found: $PlanDir"
Write-Error "Run: init-session.sh `"$PlanId`" to create it, or check .planning\ for available plans."
exit 1
}
if (-not (Test-Path $PlanRoot)) {
New-Item -ItemType Directory -Path $PlanRoot -Force | Out-Null
if (-not (Test-WithinRoot $PlanRoot) -or -not (Test-WithinRoot $PlanDir)) {
Write-Error "Error: plan directory must remain within the project."
exit 1
}
$activeItem = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
if ($activeItem -and -not (Test-SafeActiveFile)) {
Write-Error "Error: the active plan pointer must be a regular file within the project."
exit 1
}
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($ActiveFile, $PlanId, $utf8NoBom)
# Replace the directory entry instead of truncating an existing inode: a
# hardlinked pointer must not overwrite another file. The exclusive temporary
# file lives beside the pointer, keeping replacement on the same filesystem.
$tempFile = Join-Path $PlanRoot ('.active_plan.' + [guid]::NewGuid().ToString('N') + '.tmp')
$createdTemp = $false
try {
$stream = [IO.File]::Open($tempFile, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
$createdTemp = $true
try {
$bytes = $utf8NoBom.GetBytes($PlanId)
$stream.Write($bytes, 0, $bytes.Length)
$stream.Flush()
} finally { $stream.Dispose() }
if ($activeItem) {
[IO.File]::Replace($tempFile, $ActiveFile, [NullString]::Value)
} else {
[IO.File]::Move($tempFile, $ActiveFile)
}
} catch {
Write-Error "Error: could not set the active plan pointer: $($_.Exception.Message)"
exit 1
} finally {
if ($createdTemp -and (Test-Path -LiteralPath $tempFile)) {
Remove-Item -LiteralPath $tempFile -Force -ErrorAction SilentlyContinue
}
}
Write-Output "Active plan set to: $PlanId"
Write-Output "Path: $PlanDir"
Write-Output ""
+323 -38
View File
@@ -1,50 +1,335 @@
#!/bin/sh
# planning-with-files: set or display the active plan pointer.
#
# Usage:
# set-active-plan.sh <plan_id> — pin .planning/.active_plan to plan_id
# set-active-plan.sh — print the current active plan (if any)
#
# The active plan is stored in .planning/.active_plan and is read by
# resolve-plan-dir.sh when no $PLAN_ID env var is set.
set -e
# List saved named plans, show the shared pointer, or change that pointer.
# Usage: set-active-plan.sh [--list|-l|PLAN_ID]
# Operates on the current project; listing never binds a host or injects data.
set -eu
PLAN_ROOT="${PWD}/.planning"
ACTIVE_FILE="${PLAN_ROOT}/.active_plan"
PWF_ROOT_PIN=""
# No args → show current active plan
if [ "${1:-}" = "" ]; then
if [ -f "${ACTIVE_FILE}" ]; then
plan_id="$(tr -d '\r\n' < "${ACTIVE_FILE}")"
if [ -n "${plan_id}" ] && [ -d "${PLAN_ROOT}/${plan_id}" ]; then
echo "Active plan: ${plan_id}"
echo "Path: ${PLAN_ROOT}/${plan_id}"
elif [ -n "${plan_id}" ]; then
echo "Active plan pointer: ${plan_id} (directory not found — stale pointer)"
else
echo "No active plan set."
fi
# Use the resolver canonicalization policy without running plan selection.
slug_is_valid() {
case "$1" in
'') return 1 ;;
*[!A-Za-z0-9._-]*) return 1 ;;
[A-Za-z0-9_]*) return 0 ;;
esac
return 1
}
# Pure-sh backslash-to-forward-slash normalizer; result lands in $NORM_OUT.
# Windows-native coreutils builds (e.g. C:\Program Files\coreutils on PATH
# ahead of Git's usr/bin) canonicalize MSYS-style /c/... input to C:\-style
# backslash output. The containment prefix match below is written with forward
# slashes, so without this normalization every canonical pair mismatches and
# resolution silently fails. On POSIX systems paths contain no backslash and
# this is the identity. A literal backslash in a Unix filename normalizes to
# "/" and at worst fails containment — the safe direction. No subshell, no
# fork: plain parameter expansion in a loop.
norm_slashes() {
NORM_OUT=""
_ns_rest="$1"
while :; do
case "${_ns_rest}" in
*\\*)
NORM_OUT="${NORM_OUT}${_ns_rest%%\\*}/"
_ns_rest="${_ns_rest#*\\}"
;;
*)
NORM_OUT="${NORM_OUT}${_ns_rest}"
break
;;
esac
done
}
# Return true when a candidate path names the Microsoft Store WindowsApps
# directory. Store app aliases are not stable interpreter binaries and may
# present as executable while refusing script execution. Matching is
# case-insensitive and works before or after Windows slash normalization.
is_windowsapps_path() {
norm_slashes "$1"
case "${NORM_OUT}" in
[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]|\
[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]/*|\
*/[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]|\
*/[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]/*) return 0 ;;
esac
return 1
}
# Select only an interpreter path the caller explicitly trusted.
# PWF_TRUSTED_PYTHON is preferred; PYTHON_BIN remains a compatibility alias.
# PATH discovery is intentionally forbidden because resolver hooks can run in
# repositories that control PATH. Windows-native absolute paths are converted
# with Git Bash's fixed system cygpath, never a PATH-selected shim.
trusted_python() {
for _tp_candidate in "${PWF_TRUSTED_PYTHON:-}" "${PYTHON_BIN:-}"; do
[ -n "${_tp_candidate}" ] || continue
case "${_tp_candidate}" in
\\\\*|//*) continue ;;
[A-Za-z]:[\\/]*)
is_windowsapps_path "${_tp_candidate}" && continue
_tp_cygpath="/usr/bin/cygpath.exe"
[ -f "${_tp_cygpath}" ] && [ -x "${_tp_cygpath}" ] || continue
_tp_candidate="$("${_tp_cygpath}" -u "${_tp_candidate}" 2>/dev/null)" \
|| continue
;;
/*) ;;
*) continue ;;
esac
is_windowsapps_path "${_tp_candidate}" && continue
[ -f "${_tp_candidate}" ] || continue
[ -x "${_tp_candidate}" ] || continue
printf "%s\n" "${_tp_candidate}"
return 0
done
return 1
}
# Portable path canonicalizer. realpath first (Linux, modern coreutils),
# then readlink -f (older GNU), then an explicitly trusted Python interpreter.
# Prints the canonical absolute path on success; prints nothing and returns 1
# on a full miss so containment fails closed. No Python spawn on the happy
# path: realpath/readlink cover Linux, WSL, Git-Bash, and modern macOS.
canonicalize() {
target="$1"
if command -v realpath >/dev/null 2>&1; then
out="$(realpath "${target}" 2>/dev/null)" && [ -n "${out}" ] && {
printf "%s\n" "${out}"; return 0; }
fi
if command -v readlink >/dev/null 2>&1; then
out="$(readlink -f "${target}" 2>/dev/null)" && [ -n "${out}" ] && {
printf "%s\n" "${out}"; return 0; }
fi
_canonical_python="$(trusted_python)" || _canonical_python=""
if [ -n "${_canonical_python}" ]; then
out="$("${_canonical_python}" -I -c "import os,sys;print(os.path.realpath(sys.argv[1]))" "${target}" 2>/dev/null)" \
&& [ -n "${out}" ] && { printf "%s\n" "${out}"; return 0; }
fi
return 1
}
# Containment guard (security A1.3): a resolved plan dir must canonicalize to a
# path under the project root (the CWD the script runs from). A symlink inside
# a valid slug dir pointing at /etc or outside the workspace would otherwise let
# the hooks hash and inject an arbitrary file. On any violation we return 1 so
# the caller treats the candidate as unresolved and falls back safely.
#
# The root canonicalizes via the relative token "." rather than the $PWD
# string. On some Windows/MSYS setups (8.3 short names, the /tmp mount alias)
# realpath("$PWD") and realpath(relative-candidate) resolve through different
# code paths and land on differently-spelled-but-equal targets, so the prefix
# match below fails and resolution silently goes dark. "." resolves through
# the same physical-cwd path candidates already use (same fix inject-plan.sh
# received earlier; the resolver kept the $PWD form until now). Both sides are
# backslash-normalized before comparison for Windows-native canonicalizers.
# The root is computed once per run: the newest-mtime scan calls this guard
# per plan dir, and each canonicalize costs a process spawn on Windows.
#
# With a PWF_PLAN_ROOT pin (issue #212) containment is checked against THAT
# root instead of the cwd: candidates arrive ${PWF_PLAN_ROOT}/-prefixed, so
# both sides canonicalize through the same path spelling. Unpinned keeps the
# relative "." root — byte-identical to the legacy check.
ROOT_REAL=""
ROOT_REAL_SET=0
is_within_root() {
candidate="$1"
if [ "${ROOT_REAL_SET}" = "0" ]; then
ROOT_REAL="$(canonicalize "${PWF_ROOT_PIN:-.}")" || ROOT_REAL=""
norm_slashes "${ROOT_REAL}"
ROOT_REAL="${NORM_OUT}"
ROOT_REAL_SET=1
fi
# Canonicalize the candidate through its cwd-RELATIVE form whenever it
# lives under ${PWD}. The candidate string is built from ${PWD} (an MSYS
# long-form spelling), while the root canonicalizes from "." (the process
# cwd, which a caller may have set with an 8.3 short-form string). A
# Windows-native realpath does not unify those spellings, so canonicalizing
# both sides from the same cwd base is the only spelling-stable comparison.
# The emitted result keeps the original absolute candidate — only the
# containment check uses the relative form.
# Pinned resolution skips the rewrite: candidate and root then share the
# ${PWF_PLAN_ROOT} spelling, so both canonicalize directly from it.
if [ -n "${PWF_ROOT_PIN}" ]; then
check_target="${candidate}"
else
echo "No active plan set."
case "${candidate}" in
"${PWD}"/*) check_target=".${candidate#"${PWD}"}" ;;
*) check_target="${candidate}" ;;
esac
fi
cand_real="$(canonicalize "${check_target}")" || cand_real=""
norm_slashes "${cand_real}"
cand_real="${NORM_OUT}"
if [ -z "${ROOT_REAL}" ] || [ -z "${cand_real}" ]; then
# Slug validation blocks textual traversal, but only successful
# canonicalization can rule out a symlink/junction escape.
return 1
fi
case "${cand_real}" in
"${ROOT_REAL}"|"${ROOT_REAL}"/*) return 0 ;;
*) return 1 ;;
esac
}
# Each phase contributes at most one status. An explicit Status line wins
# over an inline heading marker. Fenced examples and unrelated sections are
# not phases. Keep this parser aligned with the PowerShell helper.
phase_status() {
awk '
function finish() {
if (phase) {
status = primary != "" ? primary : inline_status
if (status == "complete") complete++
else if (status == "in_progress") in_progress++
else if (status == "pending") pending++
}
phase = 0; primary = ""; inline_status = ""
}
{
line = $0
sub(/\r$/, "", line)
trimmed = line
sub(/^ */, "", trimmed)
if (line ~ /^ ? ? ?```/ || line ~ /^ ? ? ?~~~/) {
marker = substr(trimmed, 1, 1)
run = 0
while (substr(trimmed, run + 1, 1) == marker) run++
if (fence == "") { fence = marker; fence_length = run }
else if (marker == fence && run >= fence_length && substr(trimmed, run + 1) ~ /^[ \t]*$/) fence = ""
next
}
if (fence != "") next
if (line ~ /^ ? ? ?###[ \t]+(Phase|Fase|المرحلة|阶段|階段)[ \t]+[0-9]+([^0-9A-Za-z_]|$)/) {
finish(); phase = 1; total++
if (match(line, /\[(complete|in_progress|pending)\]/))
inline_status = substr(line, RSTART + 1, RLENGTH - 2)
} else if (line ~ /^ ? ? ?(###|##|#)([ \t]|$)/) {
finish()
} else if (phase && primary == "" && line ~ /^ ? ? ?(-[ \t]+)?\*\*(Status:|Estado:|الحالة:|状态:|狀態:)\*\*[ \t]+(complete|in_progress|pending)([ \t]|$)/) {
sub(/^ ? ? ?(-[ \t]+)?\*\*(Status:|Estado:|الحالة:|状态:|狀態:)\*\*[ \t]+/, "", line)
sub(/[ \t].*$/, "", line)
primary = line
}
}
END {
finish()
printf "%d/%d complete, %d in_progress, %d pending", complete, total, in_progress, pending
}
' < "$1"
}
current_active() {
if [ -f "${ACTIVE_FILE}" ] && is_within_root "${ACTIVE_FILE}"; then
_current="$(tr '\r' '\n' < "${ACTIVE_FILE}")"
# Windows editors and older PowerShell defaults can leave a UTF-8 BOM.
# Treat it as an encoding marker, not part of the shared plan slug.
_utf8_bom="$(printf '\357\273\277')"
case "${_current}" in
"${_utf8_bom}"*) _current="${_current#"${_utf8_bom}"}" ;;
esac
slug_is_valid "${_current}" && printf '%s\n' "${_current}"
fi
return 0
}
list_plans() {
if [ ! -d "${PLAN_ROOT}" ]; then
printf '%s\n' 'No planning directory found.'
return 0
fi
if ! is_within_root "${PLAN_ROOT}"; then
printf '%s\n' 'Error: planning directory is outside the project or cannot be verified.' >&2
return 1
fi
_active="$(current_active)"
_found=0
printf '%s\n' 'Available plans:'
for _dir in "${PLAN_ROOT}"/*; do
[ -d "${_dir}" ] || continue
_id="${_dir##*/}"
slug_is_valid "${_id}" || continue
is_within_root "${_dir}" || continue
_plan_file="${_dir}/task_plan.md"
[ -f "${_plan_file}" ] && [ -r "${_plan_file}" ] || continue
is_within_root "${_plan_file}" || continue
_status="$(phase_status "${_plan_file}")" || continue
_marker=''
if [ "${_id}" = "${_active}" ]; then _marker=' [active]'; fi
printf '%s\n' "- ${_id}${_marker} - ${_status}"
_found=1
done
if [ "${_found}" -eq 0 ]; then
printf '%s\n' 'No named plans found.'
else
printf '%s\n' '[active] marks the shared default pointer. Set PLAN_ID to pin a session.'
fi
}
if [ "$#" -gt 1 ]; then
printf '%s\n' 'Error: list plans or set PLAN_ID in separate calls.' >&2
exit 1
fi
case "${1:-}" in
--list|-l) list_plans; exit $? ;;
--help|-h)
printf '%s\n' 'Usage: set-active-plan.sh [--list|PLAN_ID]' \
'Lists saved named plans in the current project without selecting a plan.'
exit 0 ;;
esac
if [ "${1:-}" = '' ]; then
if [ -d "${PLAN_ROOT}" ] && ! is_within_root "${PLAN_ROOT}"; then
printf '%s\n' 'Error: planning directory is outside the project or cannot be verified.' >&2
exit 1
fi
plan_id="$(current_active)"
if [ -n "${plan_id}" ] && [ -d "${PLAN_ROOT}/${plan_id}" ] && is_within_root "${PLAN_ROOT}/${plan_id}"; then
printf '%s\n' "Active plan: ${plan_id}" "Path: ${PLAN_ROOT}/${plan_id}"
elif [ -n "${plan_id}" ]; then
printf '%s\n' "Active plan pointer: ${plan_id} (directory not found or outside project - stale pointer)"
else
printf '%s\n' 'No active plan set.'
fi
exit 0
fi
PLAN_ID="$1"
PLAN_DIR="${PLAN_ROOT}/${PLAN_ID}"
if [ ! -d "${PLAN_DIR}" ]; then
echo "Error: plan directory not found: ${PLAN_DIR}" >&2
echo "Run: init-session.sh \"${PLAN_ID}\" to create it, or check .planning/ for available plans." >&2
if ! slug_is_valid "${PLAN_ID}"; then
printf '%s\n' 'Error: invalid plan ID. Use a named directory under .planning.' >&2
exit 1
fi
mkdir -p "${PLAN_ROOT}"
printf "%s\n" "${PLAN_ID}" > "${ACTIVE_FILE}"
echo "Active plan set to: ${PLAN_ID}"
echo "Path: ${PLAN_DIR}"
echo ""
echo "To pin this terminal session only:"
echo " export PLAN_ID=${PLAN_ID}"
PLAN_DIR="${PLAN_ROOT}/${PLAN_ID}"
if [ ! -d "${PLAN_DIR}" ]; then
printf '%s\n' "Error: plan directory not found: ${PLAN_DIR}" \
"Run: init-session.sh \"${PLAN_ID}\" to create it, or use --list to see available plans." >&2
exit 1
fi
if ! is_within_root "${PLAN_ROOT}" || ! is_within_root "${PLAN_DIR}"; then
printf '%s\n' 'Error: plan directory is outside the project or cannot be verified.' >&2
exit 1
fi
# A pre-existing pointer must itself be a contained regular file before writing.
if { [ -e "${ACTIVE_FILE}" ] || [ -L "${ACTIVE_FILE}" ]; } &&
{ [ -L "${ACTIVE_FILE}" ] || [ ! -f "${ACTIVE_FILE}" ] || ! is_within_root "${ACTIVE_FILE}"; }; then
printf '%s\n' 'Error: active plan pointer is not a safe file inside the project.' >&2
exit 1
fi
# Replace the pointer atomically instead of truncating a possible hardlink.
# mktemp creates a private, exclusive file beside the destination.
temp_file="$(mktemp "${PLAN_ROOT}/.active_plan.XXXXXX")" || {
printf '%s\n' 'Error: could not create the active plan pointer.' >&2
exit 1
}
trap 'rm -f "${temp_file}"' EXIT
trap 'exit 1' HUP INT TERM
printf '%s\n' "${PLAN_ID}" > "${temp_file}"
if ! mv -f "${temp_file}" "${ACTIVE_FILE}"; then
printf '%s\n' 'Error: could not replace the active plan pointer.' >&2
exit 1
fi
trap - EXIT HUP INT TERM
printf '%s\n' "Active plan set to: ${PLAN_ID}" "Path: ${PLAN_DIR}" '' \
'To pin this terminal session only:' " export PLAN_ID=${PLAN_ID}"
+19 -15
View File
@@ -181,32 +181,36 @@ Helper scripts for automation:
- `scripts/init-session.sh` — Initialize planning files. With a name arg, creates an isolated plan under `.planning/YYYY-MM-DD-<slug>/` for parallel task workflows. Without args, writes `task_plan.md` at project root (legacy mode, backward-compatible).
- `scripts/set-active-plan.sh` — Switch the active plan pointer (`.planning/.active_plan`). Run with a plan ID to switch; run without args to show which plan is current.
- `scripts/resolve-plan-dir.sh` — Resolve the active plan directory. A set `$PLAN_ID` is a binding: it resolves or resolution stops, never another plan (issue #237). With no `$PLAN_ID`, checks `.planning/.active_plan`, then newest plan dir by mtime, then falls back to project root (legacy). Used internally by hooks.
- `scripts/resolve-plan-dir.sh` — Resolve the active plan directory. A set `$PLAN_ID` is a binding: it resolves or resolution stops, never another plan (issue #237). With no `$PLAN_ID`, multiple named plans refuse selection. A single named plan may use `.planning/.active_plan` or discovery by mtime; otherwise resolution falls back to the project root (legacy). Used internally by hooks.
- `scripts/check-complete.sh` — Verify all phases in the active plan are complete.
- `scripts/session-catchup.py`: Explicit same-project session-record aggregation or bounded replay (`--metadata` / `--replay`); bare invocation does not access host history. OpenCode uses its read-only SQLite store.
- `scripts/attest-plan.sh` (and `.ps1`) — Lock the current `task_plan.md` content with a SHA-256 attestation (v2.37.0). Hooks then refuse to inject plan content if the file diverges from the attested hash. Use `--show` to print the stored hash, `--clear` to remove the attestation.
### List saved plans
To find a task before resuming it, run `sh "<skill-dir>/scripts/set-active-plan.sh" --list` or, in Windows PowerShell, `& "<skill-dir>/scripts/set-active-plan.ps1" -List`. Replace `<skill-dir>` with this installed skill directory and keep your current directory at the project root.
This read-only command lists named plans and phase progress under the current directory's `.planning/`. `[active]` marks the shared default pointer; it does not bind a session. Concurrent tasks still require each host's `PLAN_ID` or separate worktrees.
### Parallel task workflow
When working on multiple tasks in the same repo simultaneously:
For concurrent tasks, initialize a named plan and pin each host before starting it. Set `SKILL_DIR` to the installed skill directory in each terminal and keep your current directory at the project root:
```bash
# Start task A
.init-session.sh "Backend Refactor"
# → .planning/2026-01-10-backend-refactor/task_plan.md
# Terminal A: use the exact PLAN_ID printed by initialization.
sh "$SKILL_DIR/scripts/init-session.sh" "Backend Refactor"
export PLAN_ID=2026-09-13-backend-refactor
# Start the first agent from this terminal after setting PLAN_ID.
# Start task B in a second terminal
.init-session.sh "Incident Investigation"
# → .planning/2026-01-10-incident-investigation/task_plan.md
# Switch active plan
.set-active-plan.sh 2026-01-10-backend-refactor
# Or pin a terminal to a specific plan
export PLAN_ID=2026-01-10-backend-refactor
# Terminal B: use the different PLAN_ID printed for this task.
sh "$SKILL_DIR/scripts/init-session.sh" "Incident Investigation"
export PLAN_ID=2026-09-13-incident-investigation
# Start the second agent from this terminal after setting PLAN_ID.
```
Each session reads from its own isolated plan directory. Hooks resolve the correct plan automatically.
The IDs are examples; use the IDs printed by your initialization commands. In PowerShell, set `$env:PLAN_ID` before starting the host. Setting it inside an already-running agent's tool subprocess does not change the parent host's environment. Use separate worktrees if the host cannot be pinned per task.
Use `set-active-plan` for sequential switching of the shared default pointer. Concurrent sessions need their own `PLAN_ID` even when the listing shows `[active]`.
## Advanced Topics
+266 -16
View File
@@ -1,27 +1,245 @@
# planning-with-files: set or display the active plan pointer (PowerShell).
# planning-with-files: set, display, or list the active plan pointer (PowerShell).
#
# Usage:
# .\set-active-plan.ps1 <plan_id> - pin .planning\.active_plan to plan_id
# .\set-active-plan.ps1 - print the current active plan (if any)
# .\set-active-plan.ps1 -List - list available named plans and phase counts
# .\set-active-plan.ps1 --list - equivalent to -List and -l
param(
[string]$PlanId = ""
[Parameter(Position = 0)]
[string]$PlanId = "",
[Alias('l', '-list')]
[switch]$List,
[Alias('h', '-help')]
[switch]$Help
)
$PlanRoot = Join-Path (Get-Location) ".planning"
$ProjectRoot = (Get-Location).Path
# Windows PowerShell 5.1 can silently change directories when -File inherits
# a cwd containing wildcard characters. Recover that physical cwd only for a
# direct invocation of this script; interactive Set-Location remains primary.
if ($PSVersionTable.PSVersion.Major -eq 5 -and
[System.Management.Automation.WildcardPattern]::ContainsWildcardCharacters([Environment]::CurrentDirectory)) {
$processArgs = [Environment]::GetCommandLineArgs()
for ($index = 0; $index -lt ($processArgs.Length - 1); $index++) {
if ($processArgs[$index] -ieq '-File') {
$entryScript = $processArgs[$index + 1]
if (-not [IO.Path]::IsPathRooted($entryScript)) {
$entryScript = Join-Path ([Environment]::CurrentDirectory) $entryScript
}
if ([IO.Path]::GetFullPath($entryScript) -eq [IO.Path]::GetFullPath($PSCommandPath)) {
$ProjectRoot = [Environment]::CurrentDirectory
}
break
}
}
}
$PlanRoot = Join-Path $ProjectRoot ".planning"
$ActiveFile = Join-Path $PlanRoot ".active_plan"
$script:IsWindowsHost = [Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT
# Resolve-Path can retain a junction's spelling. Like resolve-plan-dir.ps1,
# open the actual filesystem object before checking the project boundary.
# The Windows handle supports both directories and task_plan.md files.
function Get-FinalPath {
param([string]$Path)
if (-not ("PwfPlanListingNative" -as [type])) {
Add-Type -ErrorAction Stop -TypeDefinition @'
using System;
using System.ComponentModel;
using System.Runtime.InteropServices;
using System.Text;
using Microsoft.Win32.SafeHandles;
public static class PwfPlanListingNative {
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern SafeFileHandle CreateFileW(
string name, uint access, uint share, IntPtr security,
uint creation, uint flags, IntPtr template);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern uint GetFinalPathNameByHandleW(
SafeFileHandle handle, StringBuilder path, uint length, uint flags);
[DllImport("libc", SetLastError = true)]
private static extern IntPtr realpath(string path, IntPtr resolved);
[DllImport("libc")]
private static extern void free(IntPtr pointer);
public static string FinalPath(string path) {
if (Environment.OSVersion.Platform != PlatformID.Win32NT) {
IntPtr resolved = realpath(path, IntPtr.Zero);
if (resolved == IntPtr.Zero) throw new Win32Exception(Marshal.GetLastWin32Error());
try { return Marshal.PtrToStringAnsi(resolved); }
finally { free(resolved); }
}
// OPEN_EXISTING, FILE_SHARE_READ | WRITE | DELETE, BACKUP_SEMANTICS.
using (SafeFileHandle handle = CreateFileW(
path, 0, 7, IntPtr.Zero, 3, 0x02000000, IntPtr.Zero)) {
if (handle.IsInvalid) throw new Win32Exception(Marshal.GetLastWin32Error());
StringBuilder buffer = new StringBuilder(32768);
uint length = GetFinalPathNameByHandleW(handle, buffer, (uint)buffer.Capacity, 0);
if (length == 0 || length >= buffer.Capacity)
throw new Win32Exception(Marshal.GetLastWin32Error());
string result = buffer.ToString();
if (result.StartsWith(@"\\?\UNC\", StringComparison.OrdinalIgnoreCase))
return @"\\" + result.Substring(8);
if (result.StartsWith(@"\\?\", StringComparison.OrdinalIgnoreCase))
return result.Substring(4);
return result;
}
}
}
'@
}
return [PwfPlanListingNative]::FinalPath($Path)
}
function Test-WithinRoot {
param([string]$Path)
try {
$rootReal = (Get-FinalPath $ProjectRoot).TrimEnd('\', '/')
$pathReal = (Get-FinalPath $Path).TrimEnd('\', '/')
$comparison = [StringComparison]::Ordinal
if ($script:IsWindowsHost) { $comparison = [StringComparison]::OrdinalIgnoreCase }
return $pathReal.Equals($rootReal, $comparison) -or
$pathReal.StartsWith($rootReal + [IO.Path]::DirectorySeparatorChar, $comparison)
} catch {
return $false
}
}
function Test-ValidSlug {
param([string]$Name)
return $Name -cmatch '^[A-Za-z0-9_][A-Za-z0-9._-]*\z'
}
function Test-SafeActiveFile {
param([switch]$AllowLink)
$item = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
if (-not $item) { return $false }
# Reading may follow a verified in-project link; writing must not.
return -not $item.PSIsContainer -and
($AllowLink -or (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0)) -and
(Test-WithinRoot $ActiveFile)
}
function Get-CurrentActivePlan {
if (-not (Test-SafeActiveFile -AllowLink)) { return "" }
try {
$current = ([string](Get-Content -LiteralPath $ActiveFile -Raw -Encoding UTF8 -ErrorAction Stop)).Trim([char[]]"`r`n")
if (Test-ValidSlug $current) { return $current }
} catch { }
return ""
}
function Add-PhaseStatus {
param([hashtable]$Counts, $Phase)
if ($null -eq $Phase) { return }
$status = $Phase.Inline
if ($Phase.Primary) { $status = $Phase.Primary }
if ($status) { $Counts[$status]++ }
}
function Get-PhaseStatus {
param([string]$PlanFile)
$counts = @{ total = 0; complete = 0; in_progress = 0; pending = 0 }
$phase = $null
$fence = ""
$fenceLength = 0
# The shipped translations keep status identifiers in English. Unicode
# escapes preserve Windows PowerShell 5.1 compatibility without a BOM.
$phaseLabel = '(?:Phase|Fase|\u0627\u0644\u0645\u0631\u062d\u0644\u0629|\u9636\u6bb5|\u968e\u6bb5)'
$statusLabel = '(?:Status:|Estado:|\u0627\u0644\u062d\u0627\u0644\u0629:|\u72b6\u6001\uff1a|\u72c0\u614b\uff1a)'
# Read once; an empty file is a valid plan with zero phases.
$content = [string](Get-Content -LiteralPath $PlanFile -Raw -Encoding UTF8 -ErrorAction Stop)
foreach ($line in ($content -split '\r?\n')) {
if ($fence) {
$closing = '^ {0,3}' + [regex]::Escape($fence) + '{' + $fenceLength + ',}[ \t]*$'
if ($line -match $closing) { $fence = "" }
continue
}
if ($line -match '^ {0,3}(`{3,}|~{3,})') {
$fence = $Matches[1].Substring(0, 1)
$fenceLength = $Matches[1].Length
continue
}
if ($line -cmatch ('^ {0,3}###[ \t]+' + $phaseLabel + '[ \t]+[0-9]+(?:[^0-9A-Za-z_]|$)')) {
Add-PhaseStatus $counts $phase
$phase = @{ Primary = ""; Inline = "" }
$counts.total++
if ($line -cmatch '\[(complete|in_progress|pending)\]') {
$phase.Inline = $Matches[1]
}
continue
}
if ($line -match '^ {0,3}#{1,3}(?:[ \t]+|$)') {
Add-PhaseStatus $counts $phase
$phase = $null
continue
}
if ($null -ne $phase -and -not $phase.Primary -and
$line -cmatch ('^ {0,3}(?:-[ \t]+)?\*\*' + $statusLabel + '\*\*[ \t]+(complete|in_progress|pending)(?:[ \t]|$)')) {
$phase.Primary = $Matches[1]
}
}
Add-PhaseStatus $counts $phase
return "$($counts.complete)/$($counts.total) complete, $($counts.in_progress) in_progress, $($counts.pending) pending"
}
function Show-PlanList {
if (-not (Test-Path -LiteralPath $PlanRoot -PathType Container)) {
Write-Output "No planning directory found."
return
}
if (-not (Test-WithinRoot $PlanRoot)) {
Write-Error "Error: planning directory is outside the project or cannot be verified."
exit 1
}
$active = Get-CurrentActivePlan
Write-Output "Available plans:"
Write-Output "[active] marks the shared .active_plan pointer; listing does not bind this session."
$found = $false
foreach ($plan in (Get-ChildItem -LiteralPath $PlanRoot -Directory -ErrorAction SilentlyContinue | Sort-Object Name)) {
if (-not (Test-ValidSlug $plan.Name) -or -not (Test-WithinRoot $plan.FullName)) { continue }
$planFile = Join-Path $plan.FullName "task_plan.md"
if (-not (Test-Path -LiteralPath $planFile -PathType Leaf) -or -not (Test-WithinRoot $planFile)) { continue }
try { $status = Get-PhaseStatus $planFile } catch { continue }
$marker = ""
if ($plan.Name -ceq $active) { $marker = " [active]" }
Write-Output "- $($plan.Name)$marker - $status"
$found = $true
}
if (-not $found) { Write-Output "No named plans found." }
}
if ($Help -or $PlanId -eq "--help" -or $PlanId -eq "-h") {
Write-Output "Usage: set-active-plan.ps1 [-List|-l|--list|PLAN_ID]"
exit 0
}
if ($List -or $PlanId -eq "--list" -or $PlanId -eq "-l") {
if ($List -and $PlanId) {
Write-Error "Error: list plans or set PLAN_ID in separate calls."
exit 1
}
Show-PlanList
exit 0
}
if ($PlanId -eq "") {
if (Test-Path $ActiveFile) {
$current = (Get-Content $ActiveFile -Raw -Encoding UTF8).Trim()
$current = ""
if ((Test-Path -LiteralPath $PlanRoot -PathType Container) -and -not (Test-WithinRoot $PlanRoot)) {
Write-Error "Error: planning directory is outside the project or cannot be verified."
exit 1
}
if (Test-WithinRoot $PlanRoot) { $current = Get-CurrentActivePlan }
if ($current) {
$planDir = Join-Path $PlanRoot $current
if ($current -ne "" -and (Test-Path $planDir)) {
if ((Test-Path -LiteralPath $planDir -PathType Container) -and (Test-WithinRoot $planDir)) {
Write-Output "Active plan: $current"
Write-Output "Path: $planDir"
} elseif ($current -ne "") {
Write-Output "Active plan pointer: $current (directory not found - stale pointer)"
} else {
Write-Output "No active plan set."
Write-Output "Active plan pointer: $current (directory not found or outside project - stale pointer)"
}
} else {
Write-Output "No active plan set."
@@ -29,21 +247,53 @@ if ($PlanId -eq "") {
exit 0
}
if (-not (Test-ValidSlug $PlanId)) {
Write-Error "Error: invalid plan ID. Use letters, numbers, underscores, dots, or hyphens; start with a letter, number, or underscore."
exit 1
}
$PlanDir = Join-Path $PlanRoot $PlanId
if (-not (Test-Path $PlanDir)) {
if (-not (Test-Path -LiteralPath $PlanDir -PathType Container)) {
Write-Error "Error: plan directory not found: $PlanDir"
Write-Error "Run: init-session.sh `"$PlanId`" to create it, or check .planning\ for available plans."
exit 1
}
if (-not (Test-Path $PlanRoot)) {
New-Item -ItemType Directory -Path $PlanRoot -Force | Out-Null
if (-not (Test-WithinRoot $PlanRoot) -or -not (Test-WithinRoot $PlanDir)) {
Write-Error "Error: plan directory must remain within the project."
exit 1
}
$activeItem = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
if ($activeItem -and -not (Test-SafeActiveFile)) {
Write-Error "Error: the active plan pointer must be a regular file within the project."
exit 1
}
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($ActiveFile, $PlanId, $utf8NoBom)
# Replace the directory entry instead of truncating an existing inode: a
# hardlinked pointer must not overwrite another file. The exclusive temporary
# file lives beside the pointer, keeping replacement on the same filesystem.
$tempFile = Join-Path $PlanRoot ('.active_plan.' + [guid]::NewGuid().ToString('N') + '.tmp')
$createdTemp = $false
try {
$stream = [IO.File]::Open($tempFile, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
$createdTemp = $true
try {
$bytes = $utf8NoBom.GetBytes($PlanId)
$stream.Write($bytes, 0, $bytes.Length)
$stream.Flush()
} finally { $stream.Dispose() }
if ($activeItem) {
[IO.File]::Replace($tempFile, $ActiveFile, [NullString]::Value)
} else {
[IO.File]::Move($tempFile, $ActiveFile)
}
} catch {
Write-Error "Error: could not set the active plan pointer: $($_.Exception.Message)"
exit 1
} finally {
if ($createdTemp -and (Test-Path -LiteralPath $tempFile)) {
Remove-Item -LiteralPath $tempFile -Force -ErrorAction SilentlyContinue
}
}
Write-Output "Active plan set to: $PlanId"
Write-Output "Path: $PlanDir"
Write-Output ""
+323 -38
View File
@@ -1,50 +1,335 @@
#!/bin/sh
# planning-with-files: set or display the active plan pointer.
#
# Usage:
# set-active-plan.sh <plan_id> — pin .planning/.active_plan to plan_id
# set-active-plan.sh — print the current active plan (if any)
#
# The active plan is stored in .planning/.active_plan and is read by
# resolve-plan-dir.sh when no $PLAN_ID env var is set.
set -e
# List saved named plans, show the shared pointer, or change that pointer.
# Usage: set-active-plan.sh [--list|-l|PLAN_ID]
# Operates on the current project; listing never binds a host or injects data.
set -eu
PLAN_ROOT="${PWD}/.planning"
ACTIVE_FILE="${PLAN_ROOT}/.active_plan"
PWF_ROOT_PIN=""
# No args → show current active plan
if [ "${1:-}" = "" ]; then
if [ -f "${ACTIVE_FILE}" ]; then
plan_id="$(tr -d '\r\n' < "${ACTIVE_FILE}")"
if [ -n "${plan_id}" ] && [ -d "${PLAN_ROOT}/${plan_id}" ]; then
echo "Active plan: ${plan_id}"
echo "Path: ${PLAN_ROOT}/${plan_id}"
elif [ -n "${plan_id}" ]; then
echo "Active plan pointer: ${plan_id} (directory not found — stale pointer)"
else
echo "No active plan set."
fi
# Use the resolver canonicalization policy without running plan selection.
slug_is_valid() {
case "$1" in
'') return 1 ;;
*[!A-Za-z0-9._-]*) return 1 ;;
[A-Za-z0-9_]*) return 0 ;;
esac
return 1
}
# Pure-sh backslash-to-forward-slash normalizer; result lands in $NORM_OUT.
# Windows-native coreutils builds (e.g. C:\Program Files\coreutils on PATH
# ahead of Git's usr/bin) canonicalize MSYS-style /c/... input to C:\-style
# backslash output. The containment prefix match below is written with forward
# slashes, so without this normalization every canonical pair mismatches and
# resolution silently fails. On POSIX systems paths contain no backslash and
# this is the identity. A literal backslash in a Unix filename normalizes to
# "/" and at worst fails containment — the safe direction. No subshell, no
# fork: plain parameter expansion in a loop.
norm_slashes() {
NORM_OUT=""
_ns_rest="$1"
while :; do
case "${_ns_rest}" in
*\\*)
NORM_OUT="${NORM_OUT}${_ns_rest%%\\*}/"
_ns_rest="${_ns_rest#*\\}"
;;
*)
NORM_OUT="${NORM_OUT}${_ns_rest}"
break
;;
esac
done
}
# Return true when a candidate path names the Microsoft Store WindowsApps
# directory. Store app aliases are not stable interpreter binaries and may
# present as executable while refusing script execution. Matching is
# case-insensitive and works before or after Windows slash normalization.
is_windowsapps_path() {
norm_slashes "$1"
case "${NORM_OUT}" in
[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]|\
[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]/*|\
*/[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]|\
*/[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]/*) return 0 ;;
esac
return 1
}
# Select only an interpreter path the caller explicitly trusted.
# PWF_TRUSTED_PYTHON is preferred; PYTHON_BIN remains a compatibility alias.
# PATH discovery is intentionally forbidden because resolver hooks can run in
# repositories that control PATH. Windows-native absolute paths are converted
# with Git Bash's fixed system cygpath, never a PATH-selected shim.
trusted_python() {
for _tp_candidate in "${PWF_TRUSTED_PYTHON:-}" "${PYTHON_BIN:-}"; do
[ -n "${_tp_candidate}" ] || continue
case "${_tp_candidate}" in
\\\\*|//*) continue ;;
[A-Za-z]:[\\/]*)
is_windowsapps_path "${_tp_candidate}" && continue
_tp_cygpath="/usr/bin/cygpath.exe"
[ -f "${_tp_cygpath}" ] && [ -x "${_tp_cygpath}" ] || continue
_tp_candidate="$("${_tp_cygpath}" -u "${_tp_candidate}" 2>/dev/null)" \
|| continue
;;
/*) ;;
*) continue ;;
esac
is_windowsapps_path "${_tp_candidate}" && continue
[ -f "${_tp_candidate}" ] || continue
[ -x "${_tp_candidate}" ] || continue
printf "%s\n" "${_tp_candidate}"
return 0
done
return 1
}
# Portable path canonicalizer. realpath first (Linux, modern coreutils),
# then readlink -f (older GNU), then an explicitly trusted Python interpreter.
# Prints the canonical absolute path on success; prints nothing and returns 1
# on a full miss so containment fails closed. No Python spawn on the happy
# path: realpath/readlink cover Linux, WSL, Git-Bash, and modern macOS.
canonicalize() {
target="$1"
if command -v realpath >/dev/null 2>&1; then
out="$(realpath "${target}" 2>/dev/null)" && [ -n "${out}" ] && {
printf "%s\n" "${out}"; return 0; }
fi
if command -v readlink >/dev/null 2>&1; then
out="$(readlink -f "${target}" 2>/dev/null)" && [ -n "${out}" ] && {
printf "%s\n" "${out}"; return 0; }
fi
_canonical_python="$(trusted_python)" || _canonical_python=""
if [ -n "${_canonical_python}" ]; then
out="$("${_canonical_python}" -I -c "import os,sys;print(os.path.realpath(sys.argv[1]))" "${target}" 2>/dev/null)" \
&& [ -n "${out}" ] && { printf "%s\n" "${out}"; return 0; }
fi
return 1
}
# Containment guard (security A1.3): a resolved plan dir must canonicalize to a
# path under the project root (the CWD the script runs from). A symlink inside
# a valid slug dir pointing at /etc or outside the workspace would otherwise let
# the hooks hash and inject an arbitrary file. On any violation we return 1 so
# the caller treats the candidate as unresolved and falls back safely.
#
# The root canonicalizes via the relative token "." rather than the $PWD
# string. On some Windows/MSYS setups (8.3 short names, the /tmp mount alias)
# realpath("$PWD") and realpath(relative-candidate) resolve through different
# code paths and land on differently-spelled-but-equal targets, so the prefix
# match below fails and resolution silently goes dark. "." resolves through
# the same physical-cwd path candidates already use (same fix inject-plan.sh
# received earlier; the resolver kept the $PWD form until now). Both sides are
# backslash-normalized before comparison for Windows-native canonicalizers.
# The root is computed once per run: the newest-mtime scan calls this guard
# per plan dir, and each canonicalize costs a process spawn on Windows.
#
# With a PWF_PLAN_ROOT pin (issue #212) containment is checked against THAT
# root instead of the cwd: candidates arrive ${PWF_PLAN_ROOT}/-prefixed, so
# both sides canonicalize through the same path spelling. Unpinned keeps the
# relative "." root — byte-identical to the legacy check.
ROOT_REAL=""
ROOT_REAL_SET=0
is_within_root() {
candidate="$1"
if [ "${ROOT_REAL_SET}" = "0" ]; then
ROOT_REAL="$(canonicalize "${PWF_ROOT_PIN:-.}")" || ROOT_REAL=""
norm_slashes "${ROOT_REAL}"
ROOT_REAL="${NORM_OUT}"
ROOT_REAL_SET=1
fi
# Canonicalize the candidate through its cwd-RELATIVE form whenever it
# lives under ${PWD}. The candidate string is built from ${PWD} (an MSYS
# long-form spelling), while the root canonicalizes from "." (the process
# cwd, which a caller may have set with an 8.3 short-form string). A
# Windows-native realpath does not unify those spellings, so canonicalizing
# both sides from the same cwd base is the only spelling-stable comparison.
# The emitted result keeps the original absolute candidate — only the
# containment check uses the relative form.
# Pinned resolution skips the rewrite: candidate and root then share the
# ${PWF_PLAN_ROOT} spelling, so both canonicalize directly from it.
if [ -n "${PWF_ROOT_PIN}" ]; then
check_target="${candidate}"
else
echo "No active plan set."
case "${candidate}" in
"${PWD}"/*) check_target=".${candidate#"${PWD}"}" ;;
*) check_target="${candidate}" ;;
esac
fi
cand_real="$(canonicalize "${check_target}")" || cand_real=""
norm_slashes "${cand_real}"
cand_real="${NORM_OUT}"
if [ -z "${ROOT_REAL}" ] || [ -z "${cand_real}" ]; then
# Slug validation blocks textual traversal, but only successful
# canonicalization can rule out a symlink/junction escape.
return 1
fi
case "${cand_real}" in
"${ROOT_REAL}"|"${ROOT_REAL}"/*) return 0 ;;
*) return 1 ;;
esac
}
# Each phase contributes at most one status. An explicit Status line wins
# over an inline heading marker. Fenced examples and unrelated sections are
# not phases. Keep this parser aligned with the PowerShell helper.
phase_status() {
awk '
function finish() {
if (phase) {
status = primary != "" ? primary : inline_status
if (status == "complete") complete++
else if (status == "in_progress") in_progress++
else if (status == "pending") pending++
}
phase = 0; primary = ""; inline_status = ""
}
{
line = $0
sub(/\r$/, "", line)
trimmed = line
sub(/^ */, "", trimmed)
if (line ~ /^ ? ? ?```/ || line ~ /^ ? ? ?~~~/) {
marker = substr(trimmed, 1, 1)
run = 0
while (substr(trimmed, run + 1, 1) == marker) run++
if (fence == "") { fence = marker; fence_length = run }
else if (marker == fence && run >= fence_length && substr(trimmed, run + 1) ~ /^[ \t]*$/) fence = ""
next
}
if (fence != "") next
if (line ~ /^ ? ? ?###[ \t]+(Phase|Fase|المرحلة|阶段|階段)[ \t]+[0-9]+([^0-9A-Za-z_]|$)/) {
finish(); phase = 1; total++
if (match(line, /\[(complete|in_progress|pending)\]/))
inline_status = substr(line, RSTART + 1, RLENGTH - 2)
} else if (line ~ /^ ? ? ?(###|##|#)([ \t]|$)/) {
finish()
} else if (phase && primary == "" && line ~ /^ ? ? ?(-[ \t]+)?\*\*(Status:|Estado:|الحالة:|状态:|狀態:)\*\*[ \t]+(complete|in_progress|pending)([ \t]|$)/) {
sub(/^ ? ? ?(-[ \t]+)?\*\*(Status:|Estado:|الحالة:|状态:|狀態:)\*\*[ \t]+/, "", line)
sub(/[ \t].*$/, "", line)
primary = line
}
}
END {
finish()
printf "%d/%d complete, %d in_progress, %d pending", complete, total, in_progress, pending
}
' < "$1"
}
current_active() {
if [ -f "${ACTIVE_FILE}" ] && is_within_root "${ACTIVE_FILE}"; then
_current="$(tr '\r' '\n' < "${ACTIVE_FILE}")"
# Windows editors and older PowerShell defaults can leave a UTF-8 BOM.
# Treat it as an encoding marker, not part of the shared plan slug.
_utf8_bom="$(printf '\357\273\277')"
case "${_current}" in
"${_utf8_bom}"*) _current="${_current#"${_utf8_bom}"}" ;;
esac
slug_is_valid "${_current}" && printf '%s\n' "${_current}"
fi
return 0
}
list_plans() {
if [ ! -d "${PLAN_ROOT}" ]; then
printf '%s\n' 'No planning directory found.'
return 0
fi
if ! is_within_root "${PLAN_ROOT}"; then
printf '%s\n' 'Error: planning directory is outside the project or cannot be verified.' >&2
return 1
fi
_active="$(current_active)"
_found=0
printf '%s\n' 'Available plans:'
for _dir in "${PLAN_ROOT}"/*; do
[ -d "${_dir}" ] || continue
_id="${_dir##*/}"
slug_is_valid "${_id}" || continue
is_within_root "${_dir}" || continue
_plan_file="${_dir}/task_plan.md"
[ -f "${_plan_file}" ] && [ -r "${_plan_file}" ] || continue
is_within_root "${_plan_file}" || continue
_status="$(phase_status "${_plan_file}")" || continue
_marker=''
if [ "${_id}" = "${_active}" ]; then _marker=' [active]'; fi
printf '%s\n' "- ${_id}${_marker} - ${_status}"
_found=1
done
if [ "${_found}" -eq 0 ]; then
printf '%s\n' 'No named plans found.'
else
printf '%s\n' '[active] marks the shared default pointer. Set PLAN_ID to pin a session.'
fi
}
if [ "$#" -gt 1 ]; then
printf '%s\n' 'Error: list plans or set PLAN_ID in separate calls.' >&2
exit 1
fi
case "${1:-}" in
--list|-l) list_plans; exit $? ;;
--help|-h)
printf '%s\n' 'Usage: set-active-plan.sh [--list|PLAN_ID]' \
'Lists saved named plans in the current project without selecting a plan.'
exit 0 ;;
esac
if [ "${1:-}" = '' ]; then
if [ -d "${PLAN_ROOT}" ] && ! is_within_root "${PLAN_ROOT}"; then
printf '%s\n' 'Error: planning directory is outside the project or cannot be verified.' >&2
exit 1
fi
plan_id="$(current_active)"
if [ -n "${plan_id}" ] && [ -d "${PLAN_ROOT}/${plan_id}" ] && is_within_root "${PLAN_ROOT}/${plan_id}"; then
printf '%s\n' "Active plan: ${plan_id}" "Path: ${PLAN_ROOT}/${plan_id}"
elif [ -n "${plan_id}" ]; then
printf '%s\n' "Active plan pointer: ${plan_id} (directory not found or outside project - stale pointer)"
else
printf '%s\n' 'No active plan set.'
fi
exit 0
fi
PLAN_ID="$1"
PLAN_DIR="${PLAN_ROOT}/${PLAN_ID}"
if [ ! -d "${PLAN_DIR}" ]; then
echo "Error: plan directory not found: ${PLAN_DIR}" >&2
echo "Run: init-session.sh \"${PLAN_ID}\" to create it, or check .planning/ for available plans." >&2
if ! slug_is_valid "${PLAN_ID}"; then
printf '%s\n' 'Error: invalid plan ID. Use a named directory under .planning.' >&2
exit 1
fi
mkdir -p "${PLAN_ROOT}"
printf "%s\n" "${PLAN_ID}" > "${ACTIVE_FILE}"
echo "Active plan set to: ${PLAN_ID}"
echo "Path: ${PLAN_DIR}"
echo ""
echo "To pin this terminal session only:"
echo " export PLAN_ID=${PLAN_ID}"
PLAN_DIR="${PLAN_ROOT}/${PLAN_ID}"
if [ ! -d "${PLAN_DIR}" ]; then
printf '%s\n' "Error: plan directory not found: ${PLAN_DIR}" \
"Run: init-session.sh \"${PLAN_ID}\" to create it, or use --list to see available plans." >&2
exit 1
fi
if ! is_within_root "${PLAN_ROOT}" || ! is_within_root "${PLAN_DIR}"; then
printf '%s\n' 'Error: plan directory is outside the project or cannot be verified.' >&2
exit 1
fi
# A pre-existing pointer must itself be a contained regular file before writing.
if { [ -e "${ACTIVE_FILE}" ] || [ -L "${ACTIVE_FILE}" ]; } &&
{ [ -L "${ACTIVE_FILE}" ] || [ ! -f "${ACTIVE_FILE}" ] || ! is_within_root "${ACTIVE_FILE}"; }; then
printf '%s\n' 'Error: active plan pointer is not a safe file inside the project.' >&2
exit 1
fi
# Replace the pointer atomically instead of truncating a possible hardlink.
# mktemp creates a private, exclusive file beside the destination.
temp_file="$(mktemp "${PLAN_ROOT}/.active_plan.XXXXXX")" || {
printf '%s\n' 'Error: could not create the active plan pointer.' >&2
exit 1
}
trap 'rm -f "${temp_file}"' EXIT
trap 'exit 1' HUP INT TERM
printf '%s\n' "${PLAN_ID}" > "${temp_file}"
if ! mv -f "${temp_file}" "${ACTIVE_FILE}"; then
printf '%s\n' 'Error: could not replace the active plan pointer.' >&2
exit 1
fi
trap - EXIT HUP INT TERM
printf '%s\n' "Active plan set to: ${PLAN_ID}" "Path: ${PLAN_DIR}" '' \
'To pin this terminal session only:' " export PLAN_ID=${PLAN_ID}"
+7 -1
View File
@@ -36,7 +36,7 @@ hooks:
- type: command
command: "SH=\"\"; for c in \"${PWF_SCRIPT_DIR}/skill-hook.sh\" \"${CLAUDE_SKILL_DIR}/scripts/skill-hook.sh\" \"$HOME/.cursor/skills/planning-with-files/scripts/skill-hook.sh\" \"$HOME/.claude/skills/planning-with-files/scripts/skill-hook.sh\" \"$HOME/.claude/plugins/marketplaces/planning-with-files/scripts/skill-hook.sh\"; do [ -f \"$c\" ] && { SH=\"$c\"; break; }; done; [ -n \"$SH\" ] && sh \"$SH\" --event=precompact; exit 0"
metadata:
version: "3.17.2"
version: "3.18.2"
---
@@ -212,6 +212,12 @@ Helper scripts for automation:
- `scripts/check-complete.sh` — Verify all phases complete
- `scripts/session-catchup.py`: Explicit same-project session-record aggregation or bounded replay (`--metadata` / `--replay`); bare invocation does not access host history
### List saved plans
To find a task before resuming it, run `sh "<skill-dir>/scripts/set-active-plan.sh" --list` or, in Windows PowerShell, `& "<skill-dir>/scripts/set-active-plan.ps1" -List`. Replace `<skill-dir>` with this installed skill directory and keep your current directory at the project root.
This read-only command lists named plans and phase progress under the current directory's `.planning/`. `[active]` marks the shared default pointer; it does not bind a session. Concurrent tasks still require each host's `PLAN_ID` or separate worktrees.
## Advanced Topics
- **Manus Principles:** See [reference.md](reference.md)
+266 -16
View File
@@ -1,27 +1,245 @@
# planning-with-files: set or display the active plan pointer (PowerShell).
# planning-with-files: set, display, or list the active plan pointer (PowerShell).
#
# Usage:
# .\set-active-plan.ps1 <plan_id> - pin .planning\.active_plan to plan_id
# .\set-active-plan.ps1 - print the current active plan (if any)
# .\set-active-plan.ps1 -List - list available named plans and phase counts
# .\set-active-plan.ps1 --list - equivalent to -List and -l
param(
[string]$PlanId = ""
[Parameter(Position = 0)]
[string]$PlanId = "",
[Alias('l', '-list')]
[switch]$List,
[Alias('h', '-help')]
[switch]$Help
)
$PlanRoot = Join-Path (Get-Location) ".planning"
$ProjectRoot = (Get-Location).Path
# Windows PowerShell 5.1 can silently change directories when -File inherits
# a cwd containing wildcard characters. Recover that physical cwd only for a
# direct invocation of this script; interactive Set-Location remains primary.
if ($PSVersionTable.PSVersion.Major -eq 5 -and
[System.Management.Automation.WildcardPattern]::ContainsWildcardCharacters([Environment]::CurrentDirectory)) {
$processArgs = [Environment]::GetCommandLineArgs()
for ($index = 0; $index -lt ($processArgs.Length - 1); $index++) {
if ($processArgs[$index] -ieq '-File') {
$entryScript = $processArgs[$index + 1]
if (-not [IO.Path]::IsPathRooted($entryScript)) {
$entryScript = Join-Path ([Environment]::CurrentDirectory) $entryScript
}
if ([IO.Path]::GetFullPath($entryScript) -eq [IO.Path]::GetFullPath($PSCommandPath)) {
$ProjectRoot = [Environment]::CurrentDirectory
}
break
}
}
}
$PlanRoot = Join-Path $ProjectRoot ".planning"
$ActiveFile = Join-Path $PlanRoot ".active_plan"
$script:IsWindowsHost = [Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT
# Resolve-Path can retain a junction's spelling. Like resolve-plan-dir.ps1,
# open the actual filesystem object before checking the project boundary.
# The Windows handle supports both directories and task_plan.md files.
function Get-FinalPath {
param([string]$Path)
if (-not ("PwfPlanListingNative" -as [type])) {
Add-Type -ErrorAction Stop -TypeDefinition @'
using System;
using System.ComponentModel;
using System.Runtime.InteropServices;
using System.Text;
using Microsoft.Win32.SafeHandles;
public static class PwfPlanListingNative {
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern SafeFileHandle CreateFileW(
string name, uint access, uint share, IntPtr security,
uint creation, uint flags, IntPtr template);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern uint GetFinalPathNameByHandleW(
SafeFileHandle handle, StringBuilder path, uint length, uint flags);
[DllImport("libc", SetLastError = true)]
private static extern IntPtr realpath(string path, IntPtr resolved);
[DllImport("libc")]
private static extern void free(IntPtr pointer);
public static string FinalPath(string path) {
if (Environment.OSVersion.Platform != PlatformID.Win32NT) {
IntPtr resolved = realpath(path, IntPtr.Zero);
if (resolved == IntPtr.Zero) throw new Win32Exception(Marshal.GetLastWin32Error());
try { return Marshal.PtrToStringAnsi(resolved); }
finally { free(resolved); }
}
// OPEN_EXISTING, FILE_SHARE_READ | WRITE | DELETE, BACKUP_SEMANTICS.
using (SafeFileHandle handle = CreateFileW(
path, 0, 7, IntPtr.Zero, 3, 0x02000000, IntPtr.Zero)) {
if (handle.IsInvalid) throw new Win32Exception(Marshal.GetLastWin32Error());
StringBuilder buffer = new StringBuilder(32768);
uint length = GetFinalPathNameByHandleW(handle, buffer, (uint)buffer.Capacity, 0);
if (length == 0 || length >= buffer.Capacity)
throw new Win32Exception(Marshal.GetLastWin32Error());
string result = buffer.ToString();
if (result.StartsWith(@"\\?\UNC\", StringComparison.OrdinalIgnoreCase))
return @"\\" + result.Substring(8);
if (result.StartsWith(@"\\?\", StringComparison.OrdinalIgnoreCase))
return result.Substring(4);
return result;
}
}
}
'@
}
return [PwfPlanListingNative]::FinalPath($Path)
}
function Test-WithinRoot {
param([string]$Path)
try {
$rootReal = (Get-FinalPath $ProjectRoot).TrimEnd('\', '/')
$pathReal = (Get-FinalPath $Path).TrimEnd('\', '/')
$comparison = [StringComparison]::Ordinal
if ($script:IsWindowsHost) { $comparison = [StringComparison]::OrdinalIgnoreCase }
return $pathReal.Equals($rootReal, $comparison) -or
$pathReal.StartsWith($rootReal + [IO.Path]::DirectorySeparatorChar, $comparison)
} catch {
return $false
}
}
function Test-ValidSlug {
param([string]$Name)
return $Name -cmatch '^[A-Za-z0-9_][A-Za-z0-9._-]*\z'
}
function Test-SafeActiveFile {
param([switch]$AllowLink)
$item = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
if (-not $item) { return $false }
# Reading may follow a verified in-project link; writing must not.
return -not $item.PSIsContainer -and
($AllowLink -or (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0)) -and
(Test-WithinRoot $ActiveFile)
}
function Get-CurrentActivePlan {
if (-not (Test-SafeActiveFile -AllowLink)) { return "" }
try {
$current = ([string](Get-Content -LiteralPath $ActiveFile -Raw -Encoding UTF8 -ErrorAction Stop)).Trim([char[]]"`r`n")
if (Test-ValidSlug $current) { return $current }
} catch { }
return ""
}
function Add-PhaseStatus {
param([hashtable]$Counts, $Phase)
if ($null -eq $Phase) { return }
$status = $Phase.Inline
if ($Phase.Primary) { $status = $Phase.Primary }
if ($status) { $Counts[$status]++ }
}
function Get-PhaseStatus {
param([string]$PlanFile)
$counts = @{ total = 0; complete = 0; in_progress = 0; pending = 0 }
$phase = $null
$fence = ""
$fenceLength = 0
# The shipped translations keep status identifiers in English. Unicode
# escapes preserve Windows PowerShell 5.1 compatibility without a BOM.
$phaseLabel = '(?:Phase|Fase|\u0627\u0644\u0645\u0631\u062d\u0644\u0629|\u9636\u6bb5|\u968e\u6bb5)'
$statusLabel = '(?:Status:|Estado:|\u0627\u0644\u062d\u0627\u0644\u0629:|\u72b6\u6001\uff1a|\u72c0\u614b\uff1a)'
# Read once; an empty file is a valid plan with zero phases.
$content = [string](Get-Content -LiteralPath $PlanFile -Raw -Encoding UTF8 -ErrorAction Stop)
foreach ($line in ($content -split '\r?\n')) {
if ($fence) {
$closing = '^ {0,3}' + [regex]::Escape($fence) + '{' + $fenceLength + ',}[ \t]*$'
if ($line -match $closing) { $fence = "" }
continue
}
if ($line -match '^ {0,3}(`{3,}|~{3,})') {
$fence = $Matches[1].Substring(0, 1)
$fenceLength = $Matches[1].Length
continue
}
if ($line -cmatch ('^ {0,3}###[ \t]+' + $phaseLabel + '[ \t]+[0-9]+(?:[^0-9A-Za-z_]|$)')) {
Add-PhaseStatus $counts $phase
$phase = @{ Primary = ""; Inline = "" }
$counts.total++
if ($line -cmatch '\[(complete|in_progress|pending)\]') {
$phase.Inline = $Matches[1]
}
continue
}
if ($line -match '^ {0,3}#{1,3}(?:[ \t]+|$)') {
Add-PhaseStatus $counts $phase
$phase = $null
continue
}
if ($null -ne $phase -and -not $phase.Primary -and
$line -cmatch ('^ {0,3}(?:-[ \t]+)?\*\*' + $statusLabel + '\*\*[ \t]+(complete|in_progress|pending)(?:[ \t]|$)')) {
$phase.Primary = $Matches[1]
}
}
Add-PhaseStatus $counts $phase
return "$($counts.complete)/$($counts.total) complete, $($counts.in_progress) in_progress, $($counts.pending) pending"
}
function Show-PlanList {
if (-not (Test-Path -LiteralPath $PlanRoot -PathType Container)) {
Write-Output "No planning directory found."
return
}
if (-not (Test-WithinRoot $PlanRoot)) {
Write-Error "Error: planning directory is outside the project or cannot be verified."
exit 1
}
$active = Get-CurrentActivePlan
Write-Output "Available plans:"
Write-Output "[active] marks the shared .active_plan pointer; listing does not bind this session."
$found = $false
foreach ($plan in (Get-ChildItem -LiteralPath $PlanRoot -Directory -ErrorAction SilentlyContinue | Sort-Object Name)) {
if (-not (Test-ValidSlug $plan.Name) -or -not (Test-WithinRoot $plan.FullName)) { continue }
$planFile = Join-Path $plan.FullName "task_plan.md"
if (-not (Test-Path -LiteralPath $planFile -PathType Leaf) -or -not (Test-WithinRoot $planFile)) { continue }
try { $status = Get-PhaseStatus $planFile } catch { continue }
$marker = ""
if ($plan.Name -ceq $active) { $marker = " [active]" }
Write-Output "- $($plan.Name)$marker - $status"
$found = $true
}
if (-not $found) { Write-Output "No named plans found." }
}
if ($Help -or $PlanId -eq "--help" -or $PlanId -eq "-h") {
Write-Output "Usage: set-active-plan.ps1 [-List|-l|--list|PLAN_ID]"
exit 0
}
if ($List -or $PlanId -eq "--list" -or $PlanId -eq "-l") {
if ($List -and $PlanId) {
Write-Error "Error: list plans or set PLAN_ID in separate calls."
exit 1
}
Show-PlanList
exit 0
}
if ($PlanId -eq "") {
if (Test-Path $ActiveFile) {
$current = (Get-Content $ActiveFile -Raw -Encoding UTF8).Trim()
$current = ""
if ((Test-Path -LiteralPath $PlanRoot -PathType Container) -and -not (Test-WithinRoot $PlanRoot)) {
Write-Error "Error: planning directory is outside the project or cannot be verified."
exit 1
}
if (Test-WithinRoot $PlanRoot) { $current = Get-CurrentActivePlan }
if ($current) {
$planDir = Join-Path $PlanRoot $current
if ($current -ne "" -and (Test-Path $planDir)) {
if ((Test-Path -LiteralPath $planDir -PathType Container) -and (Test-WithinRoot $planDir)) {
Write-Output "Active plan: $current"
Write-Output "Path: $planDir"
} elseif ($current -ne "") {
Write-Output "Active plan pointer: $current (directory not found - stale pointer)"
} else {
Write-Output "No active plan set."
Write-Output "Active plan pointer: $current (directory not found or outside project - stale pointer)"
}
} else {
Write-Output "No active plan set."
@@ -29,21 +247,53 @@ if ($PlanId -eq "") {
exit 0
}
if (-not (Test-ValidSlug $PlanId)) {
Write-Error "Error: invalid plan ID. Use letters, numbers, underscores, dots, or hyphens; start with a letter, number, or underscore."
exit 1
}
$PlanDir = Join-Path $PlanRoot $PlanId
if (-not (Test-Path $PlanDir)) {
if (-not (Test-Path -LiteralPath $PlanDir -PathType Container)) {
Write-Error "Error: plan directory not found: $PlanDir"
Write-Error "Run: init-session.sh `"$PlanId`" to create it, or check .planning\ for available plans."
exit 1
}
if (-not (Test-Path $PlanRoot)) {
New-Item -ItemType Directory -Path $PlanRoot -Force | Out-Null
if (-not (Test-WithinRoot $PlanRoot) -or -not (Test-WithinRoot $PlanDir)) {
Write-Error "Error: plan directory must remain within the project."
exit 1
}
$activeItem = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
if ($activeItem -and -not (Test-SafeActiveFile)) {
Write-Error "Error: the active plan pointer must be a regular file within the project."
exit 1
}
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($ActiveFile, $PlanId, $utf8NoBom)
# Replace the directory entry instead of truncating an existing inode: a
# hardlinked pointer must not overwrite another file. The exclusive temporary
# file lives beside the pointer, keeping replacement on the same filesystem.
$tempFile = Join-Path $PlanRoot ('.active_plan.' + [guid]::NewGuid().ToString('N') + '.tmp')
$createdTemp = $false
try {
$stream = [IO.File]::Open($tempFile, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
$createdTemp = $true
try {
$bytes = $utf8NoBom.GetBytes($PlanId)
$stream.Write($bytes, 0, $bytes.Length)
$stream.Flush()
} finally { $stream.Dispose() }
if ($activeItem) {
[IO.File]::Replace($tempFile, $ActiveFile, [NullString]::Value)
} else {
[IO.File]::Move($tempFile, $ActiveFile)
}
} catch {
Write-Error "Error: could not set the active plan pointer: $($_.Exception.Message)"
exit 1
} finally {
if ($createdTemp -and (Test-Path -LiteralPath $tempFile)) {
Remove-Item -LiteralPath $tempFile -Force -ErrorAction SilentlyContinue
}
}
Write-Output "Active plan set to: $PlanId"
Write-Output "Path: $PlanDir"
Write-Output ""
+323 -38
View File
@@ -1,50 +1,335 @@
#!/bin/sh
# planning-with-files: set or display the active plan pointer.
#
# Usage:
# set-active-plan.sh <plan_id> — pin .planning/.active_plan to plan_id
# set-active-plan.sh — print the current active plan (if any)
#
# The active plan is stored in .planning/.active_plan and is read by
# resolve-plan-dir.sh when no $PLAN_ID env var is set.
set -e
# List saved named plans, show the shared pointer, or change that pointer.
# Usage: set-active-plan.sh [--list|-l|PLAN_ID]
# Operates on the current project; listing never binds a host or injects data.
set -eu
PLAN_ROOT="${PWD}/.planning"
ACTIVE_FILE="${PLAN_ROOT}/.active_plan"
PWF_ROOT_PIN=""
# No args → show current active plan
if [ "${1:-}" = "" ]; then
if [ -f "${ACTIVE_FILE}" ]; then
plan_id="$(tr -d '\r\n' < "${ACTIVE_FILE}")"
if [ -n "${plan_id}" ] && [ -d "${PLAN_ROOT}/${plan_id}" ]; then
echo "Active plan: ${plan_id}"
echo "Path: ${PLAN_ROOT}/${plan_id}"
elif [ -n "${plan_id}" ]; then
echo "Active plan pointer: ${plan_id} (directory not found — stale pointer)"
else
echo "No active plan set."
fi
# Use the resolver canonicalization policy without running plan selection.
slug_is_valid() {
case "$1" in
'') return 1 ;;
*[!A-Za-z0-9._-]*) return 1 ;;
[A-Za-z0-9_]*) return 0 ;;
esac
return 1
}
# Pure-sh backslash-to-forward-slash normalizer; result lands in $NORM_OUT.
# Windows-native coreutils builds (e.g. C:\Program Files\coreutils on PATH
# ahead of Git's usr/bin) canonicalize MSYS-style /c/... input to C:\-style
# backslash output. The containment prefix match below is written with forward
# slashes, so without this normalization every canonical pair mismatches and
# resolution silently fails. On POSIX systems paths contain no backslash and
# this is the identity. A literal backslash in a Unix filename normalizes to
# "/" and at worst fails containment — the safe direction. No subshell, no
# fork: plain parameter expansion in a loop.
norm_slashes() {
NORM_OUT=""
_ns_rest="$1"
while :; do
case "${_ns_rest}" in
*\\*)
NORM_OUT="${NORM_OUT}${_ns_rest%%\\*}/"
_ns_rest="${_ns_rest#*\\}"
;;
*)
NORM_OUT="${NORM_OUT}${_ns_rest}"
break
;;
esac
done
}
# Return true when a candidate path names the Microsoft Store WindowsApps
# directory. Store app aliases are not stable interpreter binaries and may
# present as executable while refusing script execution. Matching is
# case-insensitive and works before or after Windows slash normalization.
is_windowsapps_path() {
norm_slashes "$1"
case "${NORM_OUT}" in
[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]|\
[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]/*|\
*/[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]|\
*/[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]/*) return 0 ;;
esac
return 1
}
# Select only an interpreter path the caller explicitly trusted.
# PWF_TRUSTED_PYTHON is preferred; PYTHON_BIN remains a compatibility alias.
# PATH discovery is intentionally forbidden because resolver hooks can run in
# repositories that control PATH. Windows-native absolute paths are converted
# with Git Bash's fixed system cygpath, never a PATH-selected shim.
trusted_python() {
for _tp_candidate in "${PWF_TRUSTED_PYTHON:-}" "${PYTHON_BIN:-}"; do
[ -n "${_tp_candidate}" ] || continue
case "${_tp_candidate}" in
\\\\*|//*) continue ;;
[A-Za-z]:[\\/]*)
is_windowsapps_path "${_tp_candidate}" && continue
_tp_cygpath="/usr/bin/cygpath.exe"
[ -f "${_tp_cygpath}" ] && [ -x "${_tp_cygpath}" ] || continue
_tp_candidate="$("${_tp_cygpath}" -u "${_tp_candidate}" 2>/dev/null)" \
|| continue
;;
/*) ;;
*) continue ;;
esac
is_windowsapps_path "${_tp_candidate}" && continue
[ -f "${_tp_candidate}" ] || continue
[ -x "${_tp_candidate}" ] || continue
printf "%s\n" "${_tp_candidate}"
return 0
done
return 1
}
# Portable path canonicalizer. realpath first (Linux, modern coreutils),
# then readlink -f (older GNU), then an explicitly trusted Python interpreter.
# Prints the canonical absolute path on success; prints nothing and returns 1
# on a full miss so containment fails closed. No Python spawn on the happy
# path: realpath/readlink cover Linux, WSL, Git-Bash, and modern macOS.
canonicalize() {
target="$1"
if command -v realpath >/dev/null 2>&1; then
out="$(realpath "${target}" 2>/dev/null)" && [ -n "${out}" ] && {
printf "%s\n" "${out}"; return 0; }
fi
if command -v readlink >/dev/null 2>&1; then
out="$(readlink -f "${target}" 2>/dev/null)" && [ -n "${out}" ] && {
printf "%s\n" "${out}"; return 0; }
fi
_canonical_python="$(trusted_python)" || _canonical_python=""
if [ -n "${_canonical_python}" ]; then
out="$("${_canonical_python}" -I -c "import os,sys;print(os.path.realpath(sys.argv[1]))" "${target}" 2>/dev/null)" \
&& [ -n "${out}" ] && { printf "%s\n" "${out}"; return 0; }
fi
return 1
}
# Containment guard (security A1.3): a resolved plan dir must canonicalize to a
# path under the project root (the CWD the script runs from). A symlink inside
# a valid slug dir pointing at /etc or outside the workspace would otherwise let
# the hooks hash and inject an arbitrary file. On any violation we return 1 so
# the caller treats the candidate as unresolved and falls back safely.
#
# The root canonicalizes via the relative token "." rather than the $PWD
# string. On some Windows/MSYS setups (8.3 short names, the /tmp mount alias)
# realpath("$PWD") and realpath(relative-candidate) resolve through different
# code paths and land on differently-spelled-but-equal targets, so the prefix
# match below fails and resolution silently goes dark. "." resolves through
# the same physical-cwd path candidates already use (same fix inject-plan.sh
# received earlier; the resolver kept the $PWD form until now). Both sides are
# backslash-normalized before comparison for Windows-native canonicalizers.
# The root is computed once per run: the newest-mtime scan calls this guard
# per plan dir, and each canonicalize costs a process spawn on Windows.
#
# With a PWF_PLAN_ROOT pin (issue #212) containment is checked against THAT
# root instead of the cwd: candidates arrive ${PWF_PLAN_ROOT}/-prefixed, so
# both sides canonicalize through the same path spelling. Unpinned keeps the
# relative "." root — byte-identical to the legacy check.
ROOT_REAL=""
ROOT_REAL_SET=0
is_within_root() {
candidate="$1"
if [ "${ROOT_REAL_SET}" = "0" ]; then
ROOT_REAL="$(canonicalize "${PWF_ROOT_PIN:-.}")" || ROOT_REAL=""
norm_slashes "${ROOT_REAL}"
ROOT_REAL="${NORM_OUT}"
ROOT_REAL_SET=1
fi
# Canonicalize the candidate through its cwd-RELATIVE form whenever it
# lives under ${PWD}. The candidate string is built from ${PWD} (an MSYS
# long-form spelling), while the root canonicalizes from "." (the process
# cwd, which a caller may have set with an 8.3 short-form string). A
# Windows-native realpath does not unify those spellings, so canonicalizing
# both sides from the same cwd base is the only spelling-stable comparison.
# The emitted result keeps the original absolute candidate — only the
# containment check uses the relative form.
# Pinned resolution skips the rewrite: candidate and root then share the
# ${PWF_PLAN_ROOT} spelling, so both canonicalize directly from it.
if [ -n "${PWF_ROOT_PIN}" ]; then
check_target="${candidate}"
else
echo "No active plan set."
case "${candidate}" in
"${PWD}"/*) check_target=".${candidate#"${PWD}"}" ;;
*) check_target="${candidate}" ;;
esac
fi
cand_real="$(canonicalize "${check_target}")" || cand_real=""
norm_slashes "${cand_real}"
cand_real="${NORM_OUT}"
if [ -z "${ROOT_REAL}" ] || [ -z "${cand_real}" ]; then
# Slug validation blocks textual traversal, but only successful
# canonicalization can rule out a symlink/junction escape.
return 1
fi
case "${cand_real}" in
"${ROOT_REAL}"|"${ROOT_REAL}"/*) return 0 ;;
*) return 1 ;;
esac
}
# Each phase contributes at most one status. An explicit Status line wins
# over an inline heading marker. Fenced examples and unrelated sections are
# not phases. Keep this parser aligned with the PowerShell helper.
phase_status() {
awk '
function finish() {
if (phase) {
status = primary != "" ? primary : inline_status
if (status == "complete") complete++
else if (status == "in_progress") in_progress++
else if (status == "pending") pending++
}
phase = 0; primary = ""; inline_status = ""
}
{
line = $0
sub(/\r$/, "", line)
trimmed = line
sub(/^ */, "", trimmed)
if (line ~ /^ ? ? ?```/ || line ~ /^ ? ? ?~~~/) {
marker = substr(trimmed, 1, 1)
run = 0
while (substr(trimmed, run + 1, 1) == marker) run++
if (fence == "") { fence = marker; fence_length = run }
else if (marker == fence && run >= fence_length && substr(trimmed, run + 1) ~ /^[ \t]*$/) fence = ""
next
}
if (fence != "") next
if (line ~ /^ ? ? ?###[ \t]+(Phase|Fase|المرحلة|阶段|階段)[ \t]+[0-9]+([^0-9A-Za-z_]|$)/) {
finish(); phase = 1; total++
if (match(line, /\[(complete|in_progress|pending)\]/))
inline_status = substr(line, RSTART + 1, RLENGTH - 2)
} else if (line ~ /^ ? ? ?(###|##|#)([ \t]|$)/) {
finish()
} else if (phase && primary == "" && line ~ /^ ? ? ?(-[ \t]+)?\*\*(Status:|Estado:|الحالة:|状态:|狀態:)\*\*[ \t]+(complete|in_progress|pending)([ \t]|$)/) {
sub(/^ ? ? ?(-[ \t]+)?\*\*(Status:|Estado:|الحالة:|状态:|狀態:)\*\*[ \t]+/, "", line)
sub(/[ \t].*$/, "", line)
primary = line
}
}
END {
finish()
printf "%d/%d complete, %d in_progress, %d pending", complete, total, in_progress, pending
}
' < "$1"
}
current_active() {
if [ -f "${ACTIVE_FILE}" ] && is_within_root "${ACTIVE_FILE}"; then
_current="$(tr '\r' '\n' < "${ACTIVE_FILE}")"
# Windows editors and older PowerShell defaults can leave a UTF-8 BOM.
# Treat it as an encoding marker, not part of the shared plan slug.
_utf8_bom="$(printf '\357\273\277')"
case "${_current}" in
"${_utf8_bom}"*) _current="${_current#"${_utf8_bom}"}" ;;
esac
slug_is_valid "${_current}" && printf '%s\n' "${_current}"
fi
return 0
}
list_plans() {
if [ ! -d "${PLAN_ROOT}" ]; then
printf '%s\n' 'No planning directory found.'
return 0
fi
if ! is_within_root "${PLAN_ROOT}"; then
printf '%s\n' 'Error: planning directory is outside the project or cannot be verified.' >&2
return 1
fi
_active="$(current_active)"
_found=0
printf '%s\n' 'Available plans:'
for _dir in "${PLAN_ROOT}"/*; do
[ -d "${_dir}" ] || continue
_id="${_dir##*/}"
slug_is_valid "${_id}" || continue
is_within_root "${_dir}" || continue
_plan_file="${_dir}/task_plan.md"
[ -f "${_plan_file}" ] && [ -r "${_plan_file}" ] || continue
is_within_root "${_plan_file}" || continue
_status="$(phase_status "${_plan_file}")" || continue
_marker=''
if [ "${_id}" = "${_active}" ]; then _marker=' [active]'; fi
printf '%s\n' "- ${_id}${_marker} - ${_status}"
_found=1
done
if [ "${_found}" -eq 0 ]; then
printf '%s\n' 'No named plans found.'
else
printf '%s\n' '[active] marks the shared default pointer. Set PLAN_ID to pin a session.'
fi
}
if [ "$#" -gt 1 ]; then
printf '%s\n' 'Error: list plans or set PLAN_ID in separate calls.' >&2
exit 1
fi
case "${1:-}" in
--list|-l) list_plans; exit $? ;;
--help|-h)
printf '%s\n' 'Usage: set-active-plan.sh [--list|PLAN_ID]' \
'Lists saved named plans in the current project without selecting a plan.'
exit 0 ;;
esac
if [ "${1:-}" = '' ]; then
if [ -d "${PLAN_ROOT}" ] && ! is_within_root "${PLAN_ROOT}"; then
printf '%s\n' 'Error: planning directory is outside the project or cannot be verified.' >&2
exit 1
fi
plan_id="$(current_active)"
if [ -n "${plan_id}" ] && [ -d "${PLAN_ROOT}/${plan_id}" ] && is_within_root "${PLAN_ROOT}/${plan_id}"; then
printf '%s\n' "Active plan: ${plan_id}" "Path: ${PLAN_ROOT}/${plan_id}"
elif [ -n "${plan_id}" ]; then
printf '%s\n' "Active plan pointer: ${plan_id} (directory not found or outside project - stale pointer)"
else
printf '%s\n' 'No active plan set.'
fi
exit 0
fi
PLAN_ID="$1"
PLAN_DIR="${PLAN_ROOT}/${PLAN_ID}"
if [ ! -d "${PLAN_DIR}" ]; then
echo "Error: plan directory not found: ${PLAN_DIR}" >&2
echo "Run: init-session.sh \"${PLAN_ID}\" to create it, or check .planning/ for available plans." >&2
if ! slug_is_valid "${PLAN_ID}"; then
printf '%s\n' 'Error: invalid plan ID. Use a named directory under .planning.' >&2
exit 1
fi
mkdir -p "${PLAN_ROOT}"
printf "%s\n" "${PLAN_ID}" > "${ACTIVE_FILE}"
echo "Active plan set to: ${PLAN_ID}"
echo "Path: ${PLAN_DIR}"
echo ""
echo "To pin this terminal session only:"
echo " export PLAN_ID=${PLAN_ID}"
PLAN_DIR="${PLAN_ROOT}/${PLAN_ID}"
if [ ! -d "${PLAN_DIR}" ]; then
printf '%s\n' "Error: plan directory not found: ${PLAN_DIR}" \
"Run: init-session.sh \"${PLAN_ID}\" to create it, or use --list to see available plans." >&2
exit 1
fi
if ! is_within_root "${PLAN_ROOT}" || ! is_within_root "${PLAN_DIR}"; then
printf '%s\n' 'Error: plan directory is outside the project or cannot be verified.' >&2
exit 1
fi
# A pre-existing pointer must itself be a contained regular file before writing.
if { [ -e "${ACTIVE_FILE}" ] || [ -L "${ACTIVE_FILE}" ]; } &&
{ [ -L "${ACTIVE_FILE}" ] || [ ! -f "${ACTIVE_FILE}" ] || ! is_within_root "${ACTIVE_FILE}"; }; then
printf '%s\n' 'Error: active plan pointer is not a safe file inside the project.' >&2
exit 1
fi
# Replace the pointer atomically instead of truncating a possible hardlink.
# mktemp creates a private, exclusive file beside the destination.
temp_file="$(mktemp "${PLAN_ROOT}/.active_plan.XXXXXX")" || {
printf '%s\n' 'Error: could not create the active plan pointer.' >&2
exit 1
}
trap 'rm -f "${temp_file}"' EXIT
trap 'exit 1' HUP INT TERM
printf '%s\n' "${PLAN_ID}" > "${temp_file}"
if ! mv -f "${temp_file}" "${ACTIVE_FILE}"; then
printf '%s\n' 'Error: could not replace the active plan pointer.' >&2
exit 1
fi
trap - EXIT HUP INT TERM
printf '%s\n' "Active plan set to: ${PLAN_ID}" "Path: ${PLAN_DIR}" '' \
'To pin this terminal session only:' " export PLAN_ID=${PLAN_ID}"
+7 -1
View File
@@ -36,7 +36,7 @@ hooks:
- type: command
command: "SH=\"\"; for c in \"${PWF_SCRIPT_DIR}/skill-hook.sh\" \"${CLAUDE_SKILL_DIR}/scripts/skill-hook.sh\" \"$HOME/.factory/skills/planning-with-files/scripts/skill-hook.sh\" \"$HOME/.claude/skills/planning-with-files/scripts/skill-hook.sh\" \"$HOME/.claude/plugins/marketplaces/planning-with-files/scripts/skill-hook.sh\"; do [ -f \"$c\" ] && { SH=\"$c\"; break; }; done; [ -n \"$SH\" ] && sh \"$SH\" --event=precompact; exit 0"
metadata:
version: "3.17.2"
version: "3.18.2"
---
# Planning with Files
@@ -205,6 +205,12 @@ Helper scripts for automation:
- `scripts/check-complete.sh` — Verify all phases complete
- `scripts/session-catchup.py`: Explicit same-project session-record aggregation or bounded replay (`--metadata` / `--replay`); bare invocation does not access host history
### List saved plans
To find a task before resuming it, run `sh "<skill-dir>/scripts/set-active-plan.sh" --list` or, in Windows PowerShell, `& "<skill-dir>/scripts/set-active-plan.ps1" -List`. Replace `<skill-dir>` with this installed skill directory and keep your current directory at the project root.
This read-only command lists named plans and phase progress under the current directory's `.planning/`. `[active]` marks the shared default pointer; it does not bind a session. Concurrent tasks still require each host's `PLAN_ID` or separate worktrees.
## Advanced Topics
- **Manus Principles:** See [references.md](./references.md)
+266 -16
View File
@@ -1,27 +1,245 @@
# planning-with-files: set or display the active plan pointer (PowerShell).
# planning-with-files: set, display, or list the active plan pointer (PowerShell).
#
# Usage:
# .\set-active-plan.ps1 <plan_id> - pin .planning\.active_plan to plan_id
# .\set-active-plan.ps1 - print the current active plan (if any)
# .\set-active-plan.ps1 -List - list available named plans and phase counts
# .\set-active-plan.ps1 --list - equivalent to -List and -l
param(
[string]$PlanId = ""
[Parameter(Position = 0)]
[string]$PlanId = "",
[Alias('l', '-list')]
[switch]$List,
[Alias('h', '-help')]
[switch]$Help
)
$PlanRoot = Join-Path (Get-Location) ".planning"
$ProjectRoot = (Get-Location).Path
# Windows PowerShell 5.1 can silently change directories when -File inherits
# a cwd containing wildcard characters. Recover that physical cwd only for a
# direct invocation of this script; interactive Set-Location remains primary.
if ($PSVersionTable.PSVersion.Major -eq 5 -and
[System.Management.Automation.WildcardPattern]::ContainsWildcardCharacters([Environment]::CurrentDirectory)) {
$processArgs = [Environment]::GetCommandLineArgs()
for ($index = 0; $index -lt ($processArgs.Length - 1); $index++) {
if ($processArgs[$index] -ieq '-File') {
$entryScript = $processArgs[$index + 1]
if (-not [IO.Path]::IsPathRooted($entryScript)) {
$entryScript = Join-Path ([Environment]::CurrentDirectory) $entryScript
}
if ([IO.Path]::GetFullPath($entryScript) -eq [IO.Path]::GetFullPath($PSCommandPath)) {
$ProjectRoot = [Environment]::CurrentDirectory
}
break
}
}
}
$PlanRoot = Join-Path $ProjectRoot ".planning"
$ActiveFile = Join-Path $PlanRoot ".active_plan"
$script:IsWindowsHost = [Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT
# Resolve-Path can retain a junction's spelling. Like resolve-plan-dir.ps1,
# open the actual filesystem object before checking the project boundary.
# The Windows handle supports both directories and task_plan.md files.
function Get-FinalPath {
param([string]$Path)
if (-not ("PwfPlanListingNative" -as [type])) {
Add-Type -ErrorAction Stop -TypeDefinition @'
using System;
using System.ComponentModel;
using System.Runtime.InteropServices;
using System.Text;
using Microsoft.Win32.SafeHandles;
public static class PwfPlanListingNative {
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern SafeFileHandle CreateFileW(
string name, uint access, uint share, IntPtr security,
uint creation, uint flags, IntPtr template);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern uint GetFinalPathNameByHandleW(
SafeFileHandle handle, StringBuilder path, uint length, uint flags);
[DllImport("libc", SetLastError = true)]
private static extern IntPtr realpath(string path, IntPtr resolved);
[DllImport("libc")]
private static extern void free(IntPtr pointer);
public static string FinalPath(string path) {
if (Environment.OSVersion.Platform != PlatformID.Win32NT) {
IntPtr resolved = realpath(path, IntPtr.Zero);
if (resolved == IntPtr.Zero) throw new Win32Exception(Marshal.GetLastWin32Error());
try { return Marshal.PtrToStringAnsi(resolved); }
finally { free(resolved); }
}
// OPEN_EXISTING, FILE_SHARE_READ | WRITE | DELETE, BACKUP_SEMANTICS.
using (SafeFileHandle handle = CreateFileW(
path, 0, 7, IntPtr.Zero, 3, 0x02000000, IntPtr.Zero)) {
if (handle.IsInvalid) throw new Win32Exception(Marshal.GetLastWin32Error());
StringBuilder buffer = new StringBuilder(32768);
uint length = GetFinalPathNameByHandleW(handle, buffer, (uint)buffer.Capacity, 0);
if (length == 0 || length >= buffer.Capacity)
throw new Win32Exception(Marshal.GetLastWin32Error());
string result = buffer.ToString();
if (result.StartsWith(@"\\?\UNC\", StringComparison.OrdinalIgnoreCase))
return @"\\" + result.Substring(8);
if (result.StartsWith(@"\\?\", StringComparison.OrdinalIgnoreCase))
return result.Substring(4);
return result;
}
}
}
'@
}
return [PwfPlanListingNative]::FinalPath($Path)
}
function Test-WithinRoot {
param([string]$Path)
try {
$rootReal = (Get-FinalPath $ProjectRoot).TrimEnd('\', '/')
$pathReal = (Get-FinalPath $Path).TrimEnd('\', '/')
$comparison = [StringComparison]::Ordinal
if ($script:IsWindowsHost) { $comparison = [StringComparison]::OrdinalIgnoreCase }
return $pathReal.Equals($rootReal, $comparison) -or
$pathReal.StartsWith($rootReal + [IO.Path]::DirectorySeparatorChar, $comparison)
} catch {
return $false
}
}
function Test-ValidSlug {
param([string]$Name)
return $Name -cmatch '^[A-Za-z0-9_][A-Za-z0-9._-]*\z'
}
function Test-SafeActiveFile {
param([switch]$AllowLink)
$item = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
if (-not $item) { return $false }
# Reading may follow a verified in-project link; writing must not.
return -not $item.PSIsContainer -and
($AllowLink -or (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0)) -and
(Test-WithinRoot $ActiveFile)
}
function Get-CurrentActivePlan {
if (-not (Test-SafeActiveFile -AllowLink)) { return "" }
try {
$current = ([string](Get-Content -LiteralPath $ActiveFile -Raw -Encoding UTF8 -ErrorAction Stop)).Trim([char[]]"`r`n")
if (Test-ValidSlug $current) { return $current }
} catch { }
return ""
}
function Add-PhaseStatus {
param([hashtable]$Counts, $Phase)
if ($null -eq $Phase) { return }
$status = $Phase.Inline
if ($Phase.Primary) { $status = $Phase.Primary }
if ($status) { $Counts[$status]++ }
}
function Get-PhaseStatus {
param([string]$PlanFile)
$counts = @{ total = 0; complete = 0; in_progress = 0; pending = 0 }
$phase = $null
$fence = ""
$fenceLength = 0
# The shipped translations keep status identifiers in English. Unicode
# escapes preserve Windows PowerShell 5.1 compatibility without a BOM.
$phaseLabel = '(?:Phase|Fase|\u0627\u0644\u0645\u0631\u062d\u0644\u0629|\u9636\u6bb5|\u968e\u6bb5)'
$statusLabel = '(?:Status:|Estado:|\u0627\u0644\u062d\u0627\u0644\u0629:|\u72b6\u6001\uff1a|\u72c0\u614b\uff1a)'
# Read once; an empty file is a valid plan with zero phases.
$content = [string](Get-Content -LiteralPath $PlanFile -Raw -Encoding UTF8 -ErrorAction Stop)
foreach ($line in ($content -split '\r?\n')) {
if ($fence) {
$closing = '^ {0,3}' + [regex]::Escape($fence) + '{' + $fenceLength + ',}[ \t]*$'
if ($line -match $closing) { $fence = "" }
continue
}
if ($line -match '^ {0,3}(`{3,}|~{3,})') {
$fence = $Matches[1].Substring(0, 1)
$fenceLength = $Matches[1].Length
continue
}
if ($line -cmatch ('^ {0,3}###[ \t]+' + $phaseLabel + '[ \t]+[0-9]+(?:[^0-9A-Za-z_]|$)')) {
Add-PhaseStatus $counts $phase
$phase = @{ Primary = ""; Inline = "" }
$counts.total++
if ($line -cmatch '\[(complete|in_progress|pending)\]') {
$phase.Inline = $Matches[1]
}
continue
}
if ($line -match '^ {0,3}#{1,3}(?:[ \t]+|$)') {
Add-PhaseStatus $counts $phase
$phase = $null
continue
}
if ($null -ne $phase -and -not $phase.Primary -and
$line -cmatch ('^ {0,3}(?:-[ \t]+)?\*\*' + $statusLabel + '\*\*[ \t]+(complete|in_progress|pending)(?:[ \t]|$)')) {
$phase.Primary = $Matches[1]
}
}
Add-PhaseStatus $counts $phase
return "$($counts.complete)/$($counts.total) complete, $($counts.in_progress) in_progress, $($counts.pending) pending"
}
function Show-PlanList {
if (-not (Test-Path -LiteralPath $PlanRoot -PathType Container)) {
Write-Output "No planning directory found."
return
}
if (-not (Test-WithinRoot $PlanRoot)) {
Write-Error "Error: planning directory is outside the project or cannot be verified."
exit 1
}
$active = Get-CurrentActivePlan
Write-Output "Available plans:"
Write-Output "[active] marks the shared .active_plan pointer; listing does not bind this session."
$found = $false
foreach ($plan in (Get-ChildItem -LiteralPath $PlanRoot -Directory -ErrorAction SilentlyContinue | Sort-Object Name)) {
if (-not (Test-ValidSlug $plan.Name) -or -not (Test-WithinRoot $plan.FullName)) { continue }
$planFile = Join-Path $plan.FullName "task_plan.md"
if (-not (Test-Path -LiteralPath $planFile -PathType Leaf) -or -not (Test-WithinRoot $planFile)) { continue }
try { $status = Get-PhaseStatus $planFile } catch { continue }
$marker = ""
if ($plan.Name -ceq $active) { $marker = " [active]" }
Write-Output "- $($plan.Name)$marker - $status"
$found = $true
}
if (-not $found) { Write-Output "No named plans found." }
}
if ($Help -or $PlanId -eq "--help" -or $PlanId -eq "-h") {
Write-Output "Usage: set-active-plan.ps1 [-List|-l|--list|PLAN_ID]"
exit 0
}
if ($List -or $PlanId -eq "--list" -or $PlanId -eq "-l") {
if ($List -and $PlanId) {
Write-Error "Error: list plans or set PLAN_ID in separate calls."
exit 1
}
Show-PlanList
exit 0
}
if ($PlanId -eq "") {
if (Test-Path $ActiveFile) {
$current = (Get-Content $ActiveFile -Raw -Encoding UTF8).Trim()
$current = ""
if ((Test-Path -LiteralPath $PlanRoot -PathType Container) -and -not (Test-WithinRoot $PlanRoot)) {
Write-Error "Error: planning directory is outside the project or cannot be verified."
exit 1
}
if (Test-WithinRoot $PlanRoot) { $current = Get-CurrentActivePlan }
if ($current) {
$planDir = Join-Path $PlanRoot $current
if ($current -ne "" -and (Test-Path $planDir)) {
if ((Test-Path -LiteralPath $planDir -PathType Container) -and (Test-WithinRoot $planDir)) {
Write-Output "Active plan: $current"
Write-Output "Path: $planDir"
} elseif ($current -ne "") {
Write-Output "Active plan pointer: $current (directory not found - stale pointer)"
} else {
Write-Output "No active plan set."
Write-Output "Active plan pointer: $current (directory not found or outside project - stale pointer)"
}
} else {
Write-Output "No active plan set."
@@ -29,21 +247,53 @@ if ($PlanId -eq "") {
exit 0
}
if (-not (Test-ValidSlug $PlanId)) {
Write-Error "Error: invalid plan ID. Use letters, numbers, underscores, dots, or hyphens; start with a letter, number, or underscore."
exit 1
}
$PlanDir = Join-Path $PlanRoot $PlanId
if (-not (Test-Path $PlanDir)) {
if (-not (Test-Path -LiteralPath $PlanDir -PathType Container)) {
Write-Error "Error: plan directory not found: $PlanDir"
Write-Error "Run: init-session.sh `"$PlanId`" to create it, or check .planning\ for available plans."
exit 1
}
if (-not (Test-Path $PlanRoot)) {
New-Item -ItemType Directory -Path $PlanRoot -Force | Out-Null
if (-not (Test-WithinRoot $PlanRoot) -or -not (Test-WithinRoot $PlanDir)) {
Write-Error "Error: plan directory must remain within the project."
exit 1
}
$activeItem = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
if ($activeItem -and -not (Test-SafeActiveFile)) {
Write-Error "Error: the active plan pointer must be a regular file within the project."
exit 1
}
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($ActiveFile, $PlanId, $utf8NoBom)
# Replace the directory entry instead of truncating an existing inode: a
# hardlinked pointer must not overwrite another file. The exclusive temporary
# file lives beside the pointer, keeping replacement on the same filesystem.
$tempFile = Join-Path $PlanRoot ('.active_plan.' + [guid]::NewGuid().ToString('N') + '.tmp')
$createdTemp = $false
try {
$stream = [IO.File]::Open($tempFile, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
$createdTemp = $true
try {
$bytes = $utf8NoBom.GetBytes($PlanId)
$stream.Write($bytes, 0, $bytes.Length)
$stream.Flush()
} finally { $stream.Dispose() }
if ($activeItem) {
[IO.File]::Replace($tempFile, $ActiveFile, [NullString]::Value)
} else {
[IO.File]::Move($tempFile, $ActiveFile)
}
} catch {
Write-Error "Error: could not set the active plan pointer: $($_.Exception.Message)"
exit 1
} finally {
if ($createdTemp -and (Test-Path -LiteralPath $tempFile)) {
Remove-Item -LiteralPath $tempFile -Force -ErrorAction SilentlyContinue
}
}
Write-Output "Active plan set to: $PlanId"
Write-Output "Path: $PlanDir"
Write-Output ""
+323 -38
View File
@@ -1,50 +1,335 @@
#!/bin/sh
# planning-with-files: set or display the active plan pointer.
#
# Usage:
# set-active-plan.sh <plan_id> — pin .planning/.active_plan to plan_id
# set-active-plan.sh — print the current active plan (if any)
#
# The active plan is stored in .planning/.active_plan and is read by
# resolve-plan-dir.sh when no $PLAN_ID env var is set.
set -e
# List saved named plans, show the shared pointer, or change that pointer.
# Usage: set-active-plan.sh [--list|-l|PLAN_ID]
# Operates on the current project; listing never binds a host or injects data.
set -eu
PLAN_ROOT="${PWD}/.planning"
ACTIVE_FILE="${PLAN_ROOT}/.active_plan"
PWF_ROOT_PIN=""
# No args → show current active plan
if [ "${1:-}" = "" ]; then
if [ -f "${ACTIVE_FILE}" ]; then
plan_id="$(tr -d '\r\n' < "${ACTIVE_FILE}")"
if [ -n "${plan_id}" ] && [ -d "${PLAN_ROOT}/${plan_id}" ]; then
echo "Active plan: ${plan_id}"
echo "Path: ${PLAN_ROOT}/${plan_id}"
elif [ -n "${plan_id}" ]; then
echo "Active plan pointer: ${plan_id} (directory not found — stale pointer)"
else
echo "No active plan set."
fi
# Use the resolver canonicalization policy without running plan selection.
slug_is_valid() {
case "$1" in
'') return 1 ;;
*[!A-Za-z0-9._-]*) return 1 ;;
[A-Za-z0-9_]*) return 0 ;;
esac
return 1
}
# Pure-sh backslash-to-forward-slash normalizer; result lands in $NORM_OUT.
# Windows-native coreutils builds (e.g. C:\Program Files\coreutils on PATH
# ahead of Git's usr/bin) canonicalize MSYS-style /c/... input to C:\-style
# backslash output. The containment prefix match below is written with forward
# slashes, so without this normalization every canonical pair mismatches and
# resolution silently fails. On POSIX systems paths contain no backslash and
# this is the identity. A literal backslash in a Unix filename normalizes to
# "/" and at worst fails containment — the safe direction. No subshell, no
# fork: plain parameter expansion in a loop.
norm_slashes() {
NORM_OUT=""
_ns_rest="$1"
while :; do
case "${_ns_rest}" in
*\\*)
NORM_OUT="${NORM_OUT}${_ns_rest%%\\*}/"
_ns_rest="${_ns_rest#*\\}"
;;
*)
NORM_OUT="${NORM_OUT}${_ns_rest}"
break
;;
esac
done
}
# Return true when a candidate path names the Microsoft Store WindowsApps
# directory. Store app aliases are not stable interpreter binaries and may
# present as executable while refusing script execution. Matching is
# case-insensitive and works before or after Windows slash normalization.
is_windowsapps_path() {
norm_slashes "$1"
case "${NORM_OUT}" in
[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]|\
[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]/*|\
*/[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]|\
*/[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]/*) return 0 ;;
esac
return 1
}
# Select only an interpreter path the caller explicitly trusted.
# PWF_TRUSTED_PYTHON is preferred; PYTHON_BIN remains a compatibility alias.
# PATH discovery is intentionally forbidden because resolver hooks can run in
# repositories that control PATH. Windows-native absolute paths are converted
# with Git Bash's fixed system cygpath, never a PATH-selected shim.
trusted_python() {
for _tp_candidate in "${PWF_TRUSTED_PYTHON:-}" "${PYTHON_BIN:-}"; do
[ -n "${_tp_candidate}" ] || continue
case "${_tp_candidate}" in
\\\\*|//*) continue ;;
[A-Za-z]:[\\/]*)
is_windowsapps_path "${_tp_candidate}" && continue
_tp_cygpath="/usr/bin/cygpath.exe"
[ -f "${_tp_cygpath}" ] && [ -x "${_tp_cygpath}" ] || continue
_tp_candidate="$("${_tp_cygpath}" -u "${_tp_candidate}" 2>/dev/null)" \
|| continue
;;
/*) ;;
*) continue ;;
esac
is_windowsapps_path "${_tp_candidate}" && continue
[ -f "${_tp_candidate}" ] || continue
[ -x "${_tp_candidate}" ] || continue
printf "%s\n" "${_tp_candidate}"
return 0
done
return 1
}
# Portable path canonicalizer. realpath first (Linux, modern coreutils),
# then readlink -f (older GNU), then an explicitly trusted Python interpreter.
# Prints the canonical absolute path on success; prints nothing and returns 1
# on a full miss so containment fails closed. No Python spawn on the happy
# path: realpath/readlink cover Linux, WSL, Git-Bash, and modern macOS.
canonicalize() {
target="$1"
if command -v realpath >/dev/null 2>&1; then
out="$(realpath "${target}" 2>/dev/null)" && [ -n "${out}" ] && {
printf "%s\n" "${out}"; return 0; }
fi
if command -v readlink >/dev/null 2>&1; then
out="$(readlink -f "${target}" 2>/dev/null)" && [ -n "${out}" ] && {
printf "%s\n" "${out}"; return 0; }
fi
_canonical_python="$(trusted_python)" || _canonical_python=""
if [ -n "${_canonical_python}" ]; then
out="$("${_canonical_python}" -I -c "import os,sys;print(os.path.realpath(sys.argv[1]))" "${target}" 2>/dev/null)" \
&& [ -n "${out}" ] && { printf "%s\n" "${out}"; return 0; }
fi
return 1
}
# Containment guard (security A1.3): a resolved plan dir must canonicalize to a
# path under the project root (the CWD the script runs from). A symlink inside
# a valid slug dir pointing at /etc or outside the workspace would otherwise let
# the hooks hash and inject an arbitrary file. On any violation we return 1 so
# the caller treats the candidate as unresolved and falls back safely.
#
# The root canonicalizes via the relative token "." rather than the $PWD
# string. On some Windows/MSYS setups (8.3 short names, the /tmp mount alias)
# realpath("$PWD") and realpath(relative-candidate) resolve through different
# code paths and land on differently-spelled-but-equal targets, so the prefix
# match below fails and resolution silently goes dark. "." resolves through
# the same physical-cwd path candidates already use (same fix inject-plan.sh
# received earlier; the resolver kept the $PWD form until now). Both sides are
# backslash-normalized before comparison for Windows-native canonicalizers.
# The root is computed once per run: the newest-mtime scan calls this guard
# per plan dir, and each canonicalize costs a process spawn on Windows.
#
# With a PWF_PLAN_ROOT pin (issue #212) containment is checked against THAT
# root instead of the cwd: candidates arrive ${PWF_PLAN_ROOT}/-prefixed, so
# both sides canonicalize through the same path spelling. Unpinned keeps the
# relative "." root — byte-identical to the legacy check.
ROOT_REAL=""
ROOT_REAL_SET=0
is_within_root() {
candidate="$1"
if [ "${ROOT_REAL_SET}" = "0" ]; then
ROOT_REAL="$(canonicalize "${PWF_ROOT_PIN:-.}")" || ROOT_REAL=""
norm_slashes "${ROOT_REAL}"
ROOT_REAL="${NORM_OUT}"
ROOT_REAL_SET=1
fi
# Canonicalize the candidate through its cwd-RELATIVE form whenever it
# lives under ${PWD}. The candidate string is built from ${PWD} (an MSYS
# long-form spelling), while the root canonicalizes from "." (the process
# cwd, which a caller may have set with an 8.3 short-form string). A
# Windows-native realpath does not unify those spellings, so canonicalizing
# both sides from the same cwd base is the only spelling-stable comparison.
# The emitted result keeps the original absolute candidate — only the
# containment check uses the relative form.
# Pinned resolution skips the rewrite: candidate and root then share the
# ${PWF_PLAN_ROOT} spelling, so both canonicalize directly from it.
if [ -n "${PWF_ROOT_PIN}" ]; then
check_target="${candidate}"
else
echo "No active plan set."
case "${candidate}" in
"${PWD}"/*) check_target=".${candidate#"${PWD}"}" ;;
*) check_target="${candidate}" ;;
esac
fi
cand_real="$(canonicalize "${check_target}")" || cand_real=""
norm_slashes "${cand_real}"
cand_real="${NORM_OUT}"
if [ -z "${ROOT_REAL}" ] || [ -z "${cand_real}" ]; then
# Slug validation blocks textual traversal, but only successful
# canonicalization can rule out a symlink/junction escape.
return 1
fi
case "${cand_real}" in
"${ROOT_REAL}"|"${ROOT_REAL}"/*) return 0 ;;
*) return 1 ;;
esac
}
# Each phase contributes at most one status. An explicit Status line wins
# over an inline heading marker. Fenced examples and unrelated sections are
# not phases. Keep this parser aligned with the PowerShell helper.
phase_status() {
awk '
function finish() {
if (phase) {
status = primary != "" ? primary : inline_status
if (status == "complete") complete++
else if (status == "in_progress") in_progress++
else if (status == "pending") pending++
}
phase = 0; primary = ""; inline_status = ""
}
{
line = $0
sub(/\r$/, "", line)
trimmed = line
sub(/^ */, "", trimmed)
if (line ~ /^ ? ? ?```/ || line ~ /^ ? ? ?~~~/) {
marker = substr(trimmed, 1, 1)
run = 0
while (substr(trimmed, run + 1, 1) == marker) run++
if (fence == "") { fence = marker; fence_length = run }
else if (marker == fence && run >= fence_length && substr(trimmed, run + 1) ~ /^[ \t]*$/) fence = ""
next
}
if (fence != "") next
if (line ~ /^ ? ? ?###[ \t]+(Phase|Fase|المرحلة|阶段|階段)[ \t]+[0-9]+([^0-9A-Za-z_]|$)/) {
finish(); phase = 1; total++
if (match(line, /\[(complete|in_progress|pending)\]/))
inline_status = substr(line, RSTART + 1, RLENGTH - 2)
} else if (line ~ /^ ? ? ?(###|##|#)([ \t]|$)/) {
finish()
} else if (phase && primary == "" && line ~ /^ ? ? ?(-[ \t]+)?\*\*(Status:|Estado:|الحالة:|状态:|狀態:)\*\*[ \t]+(complete|in_progress|pending)([ \t]|$)/) {
sub(/^ ? ? ?(-[ \t]+)?\*\*(Status:|Estado:|الحالة:|状态:|狀態:)\*\*[ \t]+/, "", line)
sub(/[ \t].*$/, "", line)
primary = line
}
}
END {
finish()
printf "%d/%d complete, %d in_progress, %d pending", complete, total, in_progress, pending
}
' < "$1"
}
current_active() {
if [ -f "${ACTIVE_FILE}" ] && is_within_root "${ACTIVE_FILE}"; then
_current="$(tr '\r' '\n' < "${ACTIVE_FILE}")"
# Windows editors and older PowerShell defaults can leave a UTF-8 BOM.
# Treat it as an encoding marker, not part of the shared plan slug.
_utf8_bom="$(printf '\357\273\277')"
case "${_current}" in
"${_utf8_bom}"*) _current="${_current#"${_utf8_bom}"}" ;;
esac
slug_is_valid "${_current}" && printf '%s\n' "${_current}"
fi
return 0
}
list_plans() {
if [ ! -d "${PLAN_ROOT}" ]; then
printf '%s\n' 'No planning directory found.'
return 0
fi
if ! is_within_root "${PLAN_ROOT}"; then
printf '%s\n' 'Error: planning directory is outside the project or cannot be verified.' >&2
return 1
fi
_active="$(current_active)"
_found=0
printf '%s\n' 'Available plans:'
for _dir in "${PLAN_ROOT}"/*; do
[ -d "${_dir}" ] || continue
_id="${_dir##*/}"
slug_is_valid "${_id}" || continue
is_within_root "${_dir}" || continue
_plan_file="${_dir}/task_plan.md"
[ -f "${_plan_file}" ] && [ -r "${_plan_file}" ] || continue
is_within_root "${_plan_file}" || continue
_status="$(phase_status "${_plan_file}")" || continue
_marker=''
if [ "${_id}" = "${_active}" ]; then _marker=' [active]'; fi
printf '%s\n' "- ${_id}${_marker} - ${_status}"
_found=1
done
if [ "${_found}" -eq 0 ]; then
printf '%s\n' 'No named plans found.'
else
printf '%s\n' '[active] marks the shared default pointer. Set PLAN_ID to pin a session.'
fi
}
if [ "$#" -gt 1 ]; then
printf '%s\n' 'Error: list plans or set PLAN_ID in separate calls.' >&2
exit 1
fi
case "${1:-}" in
--list|-l) list_plans; exit $? ;;
--help|-h)
printf '%s\n' 'Usage: set-active-plan.sh [--list|PLAN_ID]' \
'Lists saved named plans in the current project without selecting a plan.'
exit 0 ;;
esac
if [ "${1:-}" = '' ]; then
if [ -d "${PLAN_ROOT}" ] && ! is_within_root "${PLAN_ROOT}"; then
printf '%s\n' 'Error: planning directory is outside the project or cannot be verified.' >&2
exit 1
fi
plan_id="$(current_active)"
if [ -n "${plan_id}" ] && [ -d "${PLAN_ROOT}/${plan_id}" ] && is_within_root "${PLAN_ROOT}/${plan_id}"; then
printf '%s\n' "Active plan: ${plan_id}" "Path: ${PLAN_ROOT}/${plan_id}"
elif [ -n "${plan_id}" ]; then
printf '%s\n' "Active plan pointer: ${plan_id} (directory not found or outside project - stale pointer)"
else
printf '%s\n' 'No active plan set.'
fi
exit 0
fi
PLAN_ID="$1"
PLAN_DIR="${PLAN_ROOT}/${PLAN_ID}"
if [ ! -d "${PLAN_DIR}" ]; then
echo "Error: plan directory not found: ${PLAN_DIR}" >&2
echo "Run: init-session.sh \"${PLAN_ID}\" to create it, or check .planning/ for available plans." >&2
if ! slug_is_valid "${PLAN_ID}"; then
printf '%s\n' 'Error: invalid plan ID. Use a named directory under .planning.' >&2
exit 1
fi
mkdir -p "${PLAN_ROOT}"
printf "%s\n" "${PLAN_ID}" > "${ACTIVE_FILE}"
echo "Active plan set to: ${PLAN_ID}"
echo "Path: ${PLAN_DIR}"
echo ""
echo "To pin this terminal session only:"
echo " export PLAN_ID=${PLAN_ID}"
PLAN_DIR="${PLAN_ROOT}/${PLAN_ID}"
if [ ! -d "${PLAN_DIR}" ]; then
printf '%s\n' "Error: plan directory not found: ${PLAN_DIR}" \
"Run: init-session.sh \"${PLAN_ID}\" to create it, or use --list to see available plans." >&2
exit 1
fi
if ! is_within_root "${PLAN_ROOT}" || ! is_within_root "${PLAN_DIR}"; then
printf '%s\n' 'Error: plan directory is outside the project or cannot be verified.' >&2
exit 1
fi
# A pre-existing pointer must itself be a contained regular file before writing.
if { [ -e "${ACTIVE_FILE}" ] || [ -L "${ACTIVE_FILE}" ]; } &&
{ [ -L "${ACTIVE_FILE}" ] || [ ! -f "${ACTIVE_FILE}" ] || ! is_within_root "${ACTIVE_FILE}"; }; then
printf '%s\n' 'Error: active plan pointer is not a safe file inside the project.' >&2
exit 1
fi
# Replace the pointer atomically instead of truncating a possible hardlink.
# mktemp creates a private, exclusive file beside the destination.
temp_file="$(mktemp "${PLAN_ROOT}/.active_plan.XXXXXX")" || {
printf '%s\n' 'Error: could not create the active plan pointer.' >&2
exit 1
}
trap 'rm -f "${temp_file}"' EXIT
trap 'exit 1' HUP INT TERM
printf '%s\n' "${PLAN_ID}" > "${temp_file}"
if ! mv -f "${temp_file}" "${ACTIVE_FILE}"; then
printf '%s\n' 'Error: could not replace the active plan pointer.' >&2
exit 1
fi
trap - EXIT HUP INT TERM
printf '%s\n' "Active plan set to: ${PLAN_ID}" "Path: ${PLAN_DIR}" '' \
'To pin this terminal session only:' " export PLAN_ID=${PLAN_ID}"
+1 -1
View File
@@ -18,7 +18,7 @@ CURRENT_PHASE=$(grep -m1 "^## Current Phase" "$PLAN_FILE" 2>/dev/null || grep -m
if [ -n "$CURRENT_PHASE" ]; then
PYTHON=$(command -v python3 || command -v python)
ESCAPED=$($PYTHON -c "import sys,json; print(json.dumps(sys.stdin.read(), ensure_ascii=False))" <<< "[planning-with-files] Current: $CURRENT_PHASE" 2>/dev/null || echo "\"\"")
ESCAPED=$($PYTHON -I -X utf8 -c "import sys,json; print(json.dumps(sys.stdin.read(), ensure_ascii=False))" <<< "[planning-with-files] Current: $CURRENT_PHASE" 2>/dev/null || echo "\"\"")
printf '%s\n' "{\"additionalContext\":$ESCAPED}"
else
echo '{}'
+1 -1
View File
@@ -21,7 +21,7 @@ fi
PYTHON=$(command -v python3 || command -v python)
if [ -n "$PYTHON" ]; then
ESCAPED=$($PYTHON -c "import sys,json; print(json.dumps(sys.stdin.read(), ensure_ascii=False))" <<< "$CONTEXT" 2>/dev/null)
ESCAPED=$($PYTHON -I -X utf8 -c "import sys,json; print(json.dumps(sys.stdin.read(), ensure_ascii=False))" <<< "$CONTEXT" 2>/dev/null)
if [ -n "$ESCAPED" ] && [ "$ESCAPED" != "\"\"" ]; then
printf '%s\n' "{\"systemMessage\":$ESCAPED}"
exit 0
+1 -1
View File
@@ -18,7 +18,7 @@ if [ -f "$SCRIPT_DIR/check-complete.sh" ]; then
RESULT=$(sh "$SCRIPT_DIR/check-complete.sh" 2>/dev/null || true)
if [ -n "$RESULT" ]; then
PYTHON=$(command -v python3 || command -v python)
ESCAPED=$($PYTHON -c "import sys,json; print(json.dumps(sys.stdin.read(), ensure_ascii=False))" <<< "$RESULT" 2>/dev/null || echo "\"\"")
ESCAPED=$($PYTHON -I -X utf8 -c "import sys,json; print(json.dumps(sys.stdin.read(), ensure_ascii=False))" <<< "$RESULT" 2>/dev/null || echo "\"\"")
printf '%s\n' "{\"systemMessage\":$ESCAPED}"
exit 0
fi
+2 -2
View File
@@ -19,11 +19,11 @@ fi
PYTHON=$(command -v python3 || command -v python)
CATCHUP=""
if [ -n "$PYTHON" ] && [ -f "$SCRIPT_DIR/session-catchup.py" ]; then
CATCHUP=$($PYTHON "$SCRIPT_DIR/session-catchup.py" --no-history "$(pwd)" 2>/dev/null || true)
CATCHUP=$($PYTHON -I -X utf8 "$SCRIPT_DIR/session-catchup.py" --no-history "$(pwd)" 2>/dev/null || true)
fi
if [ -n "$CATCHUP" ]; then
ESCAPED=$($PYTHON -c "import sys,json; print(json.dumps(sys.stdin.read(), ensure_ascii=False))" <<< "$CATCHUP" 2>/dev/null || echo "\"[planning-with-files] Session recovery data available. Read task_plan.md, progress.md, and findings.md.\"")
ESCAPED=$($PYTHON -I -X utf8 -c "import sys,json; print(json.dumps(sys.stdin.read(), ensure_ascii=False))" <<< "$CATCHUP" 2>/dev/null || echo "\"[planning-with-files] Session recovery data available. Read task_plan.md, progress.md, and findings.md.\"")
printf '%s\n' "{\"hookSpecificOutput\":{\"additionalContext\":$ESCAPED}}"
else
echo '{"hookSpecificOutput":{"additionalContext":"[planning-with-files] Active plan detected. Read task_plan.md, progress.md, and findings.md before proceeding."}}'
+19 -15
View File
@@ -182,32 +182,36 @@ Helper scripts for automation:
- `scripts/init-session.sh` — Initialize planning files. With a name arg, creates an isolated plan under `.planning/YYYY-MM-DD-<slug>/` for parallel task workflows. Without args, writes `task_plan.md` at project root (legacy mode, backward-compatible).
- `scripts/set-active-plan.sh` — Switch the active plan pointer (`.planning/.active_plan`). Run with a plan ID to switch; run without args to show which plan is current.
- `scripts/resolve-plan-dir.sh` — Resolve the active plan directory. A set `$PLAN_ID` is a binding: it resolves or resolution stops, never another plan (issue #237). With no `$PLAN_ID`, checks `.planning/.active_plan`, then newest plan dir by mtime, then falls back to project root (legacy). Used internally by hooks.
- `scripts/resolve-plan-dir.sh` — Resolve the active plan directory. A set `$PLAN_ID` is a binding: it resolves or resolution stops, never another plan (issue #237). With no `$PLAN_ID`, multiple named plans refuse selection. A single named plan may use `.planning/.active_plan` or discovery by mtime; otherwise resolution falls back to the project root (legacy). Used internally by hooks.
- `scripts/check-complete.sh` — Verify all phases in the active plan are complete.
- `scripts/session-catchup.py`: Explicit same-project session-record aggregation or bounded replay (`--metadata` / `--replay`); bare invocation does not access host history. OpenCode uses its read-only SQLite store.
- `scripts/attest-plan.sh` (and `.ps1`) — Lock the current `task_plan.md` content with a SHA-256 attestation (v2.37.0). Use `--show` to print the stored hash, `--clear` to remove the attestation.
### List saved plans
To find a task before resuming it, run `sh "<skill-dir>/scripts/set-active-plan.sh" --list` or, in Windows PowerShell, `& "<skill-dir>/scripts/set-active-plan.ps1" -List`. Replace `<skill-dir>` with this installed skill directory and keep your current directory at the project root.
This read-only command lists named plans and phase progress under the current directory's `.planning/`. `[active]` marks the shared default pointer; it does not bind a session. Concurrent tasks still require each host's `PLAN_ID` or separate worktrees.
### Parallel task workflow
When working on multiple tasks in the same repo simultaneously:
For concurrent tasks, initialize a named plan and pin each host before starting it. Set `SKILL_DIR` to the installed skill directory in each terminal and keep your current directory at the project root:
```bash
# Start task A
./scripts/init-session.sh "Backend Refactor"
# → .planning/2026-01-10-backend-refactor/task_plan.md
# Terminal A: use the exact PLAN_ID printed by initialization.
sh "$SKILL_DIR/scripts/init-session.sh" "Backend Refactor"
export PLAN_ID=2026-09-13-backend-refactor
# Start the first agent from this terminal after setting PLAN_ID.
# Start task B in a second terminal
./scripts/init-session.sh "Incident Investigation"
# → .planning/2026-01-10-incident-investigation/task_plan.md
# Switch active plan
./scripts/set-active-plan.sh 2026-01-10-backend-refactor
# Or pin a terminal to a specific plan
export PLAN_ID=2026-01-10-backend-refactor
# Terminal B: use the different PLAN_ID printed for this task.
sh "$SKILL_DIR/scripts/init-session.sh" "Incident Investigation"
export PLAN_ID=2026-09-13-incident-investigation
# Start the second agent from this terminal after setting PLAN_ID.
```
Each session reads from its own isolated plan directory.
The IDs are examples; use the IDs printed by your initialization commands. In PowerShell, set `$env:PLAN_ID` before starting the host. Setting it inside an already-running agent's tool subprocess does not change the parent host's environment. Use separate worktrees if the host cannot be pinned per task.
Use `set-active-plan` for sequential switching of the shared default pointer. Concurrent sessions need their own `PLAN_ID` even when the listing shows `[active]`.
## Advanced Topics
+266 -16
View File
@@ -1,27 +1,245 @@
# planning-with-files: set or display the active plan pointer (PowerShell).
# planning-with-files: set, display, or list the active plan pointer (PowerShell).
#
# Usage:
# .\set-active-plan.ps1 <plan_id> - pin .planning\.active_plan to plan_id
# .\set-active-plan.ps1 - print the current active plan (if any)
# .\set-active-plan.ps1 -List - list available named plans and phase counts
# .\set-active-plan.ps1 --list - equivalent to -List and -l
param(
[string]$PlanId = ""
[Parameter(Position = 0)]
[string]$PlanId = "",
[Alias('l', '-list')]
[switch]$List,
[Alias('h', '-help')]
[switch]$Help
)
$PlanRoot = Join-Path (Get-Location) ".planning"
$ProjectRoot = (Get-Location).Path
# Windows PowerShell 5.1 can silently change directories when -File inherits
# a cwd containing wildcard characters. Recover that physical cwd only for a
# direct invocation of this script; interactive Set-Location remains primary.
if ($PSVersionTable.PSVersion.Major -eq 5 -and
[System.Management.Automation.WildcardPattern]::ContainsWildcardCharacters([Environment]::CurrentDirectory)) {
$processArgs = [Environment]::GetCommandLineArgs()
for ($index = 0; $index -lt ($processArgs.Length - 1); $index++) {
if ($processArgs[$index] -ieq '-File') {
$entryScript = $processArgs[$index + 1]
if (-not [IO.Path]::IsPathRooted($entryScript)) {
$entryScript = Join-Path ([Environment]::CurrentDirectory) $entryScript
}
if ([IO.Path]::GetFullPath($entryScript) -eq [IO.Path]::GetFullPath($PSCommandPath)) {
$ProjectRoot = [Environment]::CurrentDirectory
}
break
}
}
}
$PlanRoot = Join-Path $ProjectRoot ".planning"
$ActiveFile = Join-Path $PlanRoot ".active_plan"
$script:IsWindowsHost = [Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT
# Resolve-Path can retain a junction's spelling. Like resolve-plan-dir.ps1,
# open the actual filesystem object before checking the project boundary.
# The Windows handle supports both directories and task_plan.md files.
function Get-FinalPath {
param([string]$Path)
if (-not ("PwfPlanListingNative" -as [type])) {
Add-Type -ErrorAction Stop -TypeDefinition @'
using System;
using System.ComponentModel;
using System.Runtime.InteropServices;
using System.Text;
using Microsoft.Win32.SafeHandles;
public static class PwfPlanListingNative {
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern SafeFileHandle CreateFileW(
string name, uint access, uint share, IntPtr security,
uint creation, uint flags, IntPtr template);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern uint GetFinalPathNameByHandleW(
SafeFileHandle handle, StringBuilder path, uint length, uint flags);
[DllImport("libc", SetLastError = true)]
private static extern IntPtr realpath(string path, IntPtr resolved);
[DllImport("libc")]
private static extern void free(IntPtr pointer);
public static string FinalPath(string path) {
if (Environment.OSVersion.Platform != PlatformID.Win32NT) {
IntPtr resolved = realpath(path, IntPtr.Zero);
if (resolved == IntPtr.Zero) throw new Win32Exception(Marshal.GetLastWin32Error());
try { return Marshal.PtrToStringAnsi(resolved); }
finally { free(resolved); }
}
// OPEN_EXISTING, FILE_SHARE_READ | WRITE | DELETE, BACKUP_SEMANTICS.
using (SafeFileHandle handle = CreateFileW(
path, 0, 7, IntPtr.Zero, 3, 0x02000000, IntPtr.Zero)) {
if (handle.IsInvalid) throw new Win32Exception(Marshal.GetLastWin32Error());
StringBuilder buffer = new StringBuilder(32768);
uint length = GetFinalPathNameByHandleW(handle, buffer, (uint)buffer.Capacity, 0);
if (length == 0 || length >= buffer.Capacity)
throw new Win32Exception(Marshal.GetLastWin32Error());
string result = buffer.ToString();
if (result.StartsWith(@"\\?\UNC\", StringComparison.OrdinalIgnoreCase))
return @"\\" + result.Substring(8);
if (result.StartsWith(@"\\?\", StringComparison.OrdinalIgnoreCase))
return result.Substring(4);
return result;
}
}
}
'@
}
return [PwfPlanListingNative]::FinalPath($Path)
}
function Test-WithinRoot {
param([string]$Path)
try {
$rootReal = (Get-FinalPath $ProjectRoot).TrimEnd('\', '/')
$pathReal = (Get-FinalPath $Path).TrimEnd('\', '/')
$comparison = [StringComparison]::Ordinal
if ($script:IsWindowsHost) { $comparison = [StringComparison]::OrdinalIgnoreCase }
return $pathReal.Equals($rootReal, $comparison) -or
$pathReal.StartsWith($rootReal + [IO.Path]::DirectorySeparatorChar, $comparison)
} catch {
return $false
}
}
function Test-ValidSlug {
param([string]$Name)
return $Name -cmatch '^[A-Za-z0-9_][A-Za-z0-9._-]*\z'
}
function Test-SafeActiveFile {
param([switch]$AllowLink)
$item = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
if (-not $item) { return $false }
# Reading may follow a verified in-project link; writing must not.
return -not $item.PSIsContainer -and
($AllowLink -or (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0)) -and
(Test-WithinRoot $ActiveFile)
}
function Get-CurrentActivePlan {
if (-not (Test-SafeActiveFile -AllowLink)) { return "" }
try {
$current = ([string](Get-Content -LiteralPath $ActiveFile -Raw -Encoding UTF8 -ErrorAction Stop)).Trim([char[]]"`r`n")
if (Test-ValidSlug $current) { return $current }
} catch { }
return ""
}
function Add-PhaseStatus {
param([hashtable]$Counts, $Phase)
if ($null -eq $Phase) { return }
$status = $Phase.Inline
if ($Phase.Primary) { $status = $Phase.Primary }
if ($status) { $Counts[$status]++ }
}
function Get-PhaseStatus {
param([string]$PlanFile)
$counts = @{ total = 0; complete = 0; in_progress = 0; pending = 0 }
$phase = $null
$fence = ""
$fenceLength = 0
# The shipped translations keep status identifiers in English. Unicode
# escapes preserve Windows PowerShell 5.1 compatibility without a BOM.
$phaseLabel = '(?:Phase|Fase|\u0627\u0644\u0645\u0631\u062d\u0644\u0629|\u9636\u6bb5|\u968e\u6bb5)'
$statusLabel = '(?:Status:|Estado:|\u0627\u0644\u062d\u0627\u0644\u0629:|\u72b6\u6001\uff1a|\u72c0\u614b\uff1a)'
# Read once; an empty file is a valid plan with zero phases.
$content = [string](Get-Content -LiteralPath $PlanFile -Raw -Encoding UTF8 -ErrorAction Stop)
foreach ($line in ($content -split '\r?\n')) {
if ($fence) {
$closing = '^ {0,3}' + [regex]::Escape($fence) + '{' + $fenceLength + ',}[ \t]*$'
if ($line -match $closing) { $fence = "" }
continue
}
if ($line -match '^ {0,3}(`{3,}|~{3,})') {
$fence = $Matches[1].Substring(0, 1)
$fenceLength = $Matches[1].Length
continue
}
if ($line -cmatch ('^ {0,3}###[ \t]+' + $phaseLabel + '[ \t]+[0-9]+(?:[^0-9A-Za-z_]|$)')) {
Add-PhaseStatus $counts $phase
$phase = @{ Primary = ""; Inline = "" }
$counts.total++
if ($line -cmatch '\[(complete|in_progress|pending)\]') {
$phase.Inline = $Matches[1]
}
continue
}
if ($line -match '^ {0,3}#{1,3}(?:[ \t]+|$)') {
Add-PhaseStatus $counts $phase
$phase = $null
continue
}
if ($null -ne $phase -and -not $phase.Primary -and
$line -cmatch ('^ {0,3}(?:-[ \t]+)?\*\*' + $statusLabel + '\*\*[ \t]+(complete|in_progress|pending)(?:[ \t]|$)')) {
$phase.Primary = $Matches[1]
}
}
Add-PhaseStatus $counts $phase
return "$($counts.complete)/$($counts.total) complete, $($counts.in_progress) in_progress, $($counts.pending) pending"
}
function Show-PlanList {
if (-not (Test-Path -LiteralPath $PlanRoot -PathType Container)) {
Write-Output "No planning directory found."
return
}
if (-not (Test-WithinRoot $PlanRoot)) {
Write-Error "Error: planning directory is outside the project or cannot be verified."
exit 1
}
$active = Get-CurrentActivePlan
Write-Output "Available plans:"
Write-Output "[active] marks the shared .active_plan pointer; listing does not bind this session."
$found = $false
foreach ($plan in (Get-ChildItem -LiteralPath $PlanRoot -Directory -ErrorAction SilentlyContinue | Sort-Object Name)) {
if (-not (Test-ValidSlug $plan.Name) -or -not (Test-WithinRoot $plan.FullName)) { continue }
$planFile = Join-Path $plan.FullName "task_plan.md"
if (-not (Test-Path -LiteralPath $planFile -PathType Leaf) -or -not (Test-WithinRoot $planFile)) { continue }
try { $status = Get-PhaseStatus $planFile } catch { continue }
$marker = ""
if ($plan.Name -ceq $active) { $marker = " [active]" }
Write-Output "- $($plan.Name)$marker - $status"
$found = $true
}
if (-not $found) { Write-Output "No named plans found." }
}
if ($Help -or $PlanId -eq "--help" -or $PlanId -eq "-h") {
Write-Output "Usage: set-active-plan.ps1 [-List|-l|--list|PLAN_ID]"
exit 0
}
if ($List -or $PlanId -eq "--list" -or $PlanId -eq "-l") {
if ($List -and $PlanId) {
Write-Error "Error: list plans or set PLAN_ID in separate calls."
exit 1
}
Show-PlanList
exit 0
}
if ($PlanId -eq "") {
if (Test-Path $ActiveFile) {
$current = (Get-Content $ActiveFile -Raw -Encoding UTF8).Trim()
$current = ""
if ((Test-Path -LiteralPath $PlanRoot -PathType Container) -and -not (Test-WithinRoot $PlanRoot)) {
Write-Error "Error: planning directory is outside the project or cannot be verified."
exit 1
}
if (Test-WithinRoot $PlanRoot) { $current = Get-CurrentActivePlan }
if ($current) {
$planDir = Join-Path $PlanRoot $current
if ($current -ne "" -and (Test-Path $planDir)) {
if ((Test-Path -LiteralPath $planDir -PathType Container) -and (Test-WithinRoot $planDir)) {
Write-Output "Active plan: $current"
Write-Output "Path: $planDir"
} elseif ($current -ne "") {
Write-Output "Active plan pointer: $current (directory not found - stale pointer)"
} else {
Write-Output "No active plan set."
Write-Output "Active plan pointer: $current (directory not found or outside project - stale pointer)"
}
} else {
Write-Output "No active plan set."
@@ -29,21 +247,53 @@ if ($PlanId -eq "") {
exit 0
}
if (-not (Test-ValidSlug $PlanId)) {
Write-Error "Error: invalid plan ID. Use letters, numbers, underscores, dots, or hyphens; start with a letter, number, or underscore."
exit 1
}
$PlanDir = Join-Path $PlanRoot $PlanId
if (-not (Test-Path $PlanDir)) {
if (-not (Test-Path -LiteralPath $PlanDir -PathType Container)) {
Write-Error "Error: plan directory not found: $PlanDir"
Write-Error "Run: init-session.sh `"$PlanId`" to create it, or check .planning\ for available plans."
exit 1
}
if (-not (Test-Path $PlanRoot)) {
New-Item -ItemType Directory -Path $PlanRoot -Force | Out-Null
if (-not (Test-WithinRoot $PlanRoot) -or -not (Test-WithinRoot $PlanDir)) {
Write-Error "Error: plan directory must remain within the project."
exit 1
}
$activeItem = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
if ($activeItem -and -not (Test-SafeActiveFile)) {
Write-Error "Error: the active plan pointer must be a regular file within the project."
exit 1
}
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($ActiveFile, $PlanId, $utf8NoBom)
# Replace the directory entry instead of truncating an existing inode: a
# hardlinked pointer must not overwrite another file. The exclusive temporary
# file lives beside the pointer, keeping replacement on the same filesystem.
$tempFile = Join-Path $PlanRoot ('.active_plan.' + [guid]::NewGuid().ToString('N') + '.tmp')
$createdTemp = $false
try {
$stream = [IO.File]::Open($tempFile, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
$createdTemp = $true
try {
$bytes = $utf8NoBom.GetBytes($PlanId)
$stream.Write($bytes, 0, $bytes.Length)
$stream.Flush()
} finally { $stream.Dispose() }
if ($activeItem) {
[IO.File]::Replace($tempFile, $ActiveFile, [NullString]::Value)
} else {
[IO.File]::Move($tempFile, $ActiveFile)
}
} catch {
Write-Error "Error: could not set the active plan pointer: $($_.Exception.Message)"
exit 1
} finally {
if ($createdTemp -and (Test-Path -LiteralPath $tempFile)) {
Remove-Item -LiteralPath $tempFile -Force -ErrorAction SilentlyContinue
}
}
Write-Output "Active plan set to: $PlanId"
Write-Output "Path: $PlanDir"
Write-Output ""
+323 -38
View File
@@ -1,50 +1,335 @@
#!/bin/sh
# planning-with-files: set or display the active plan pointer.
#
# Usage:
# set-active-plan.sh <plan_id> — pin .planning/.active_plan to plan_id
# set-active-plan.sh — print the current active plan (if any)
#
# The active plan is stored in .planning/.active_plan and is read by
# resolve-plan-dir.sh when no $PLAN_ID env var is set.
set -e
# List saved named plans, show the shared pointer, or change that pointer.
# Usage: set-active-plan.sh [--list|-l|PLAN_ID]
# Operates on the current project; listing never binds a host or injects data.
set -eu
PLAN_ROOT="${PWD}/.planning"
ACTIVE_FILE="${PLAN_ROOT}/.active_plan"
PWF_ROOT_PIN=""
# No args → show current active plan
if [ "${1:-}" = "" ]; then
if [ -f "${ACTIVE_FILE}" ]; then
plan_id="$(tr -d '\r\n' < "${ACTIVE_FILE}")"
if [ -n "${plan_id}" ] && [ -d "${PLAN_ROOT}/${plan_id}" ]; then
echo "Active plan: ${plan_id}"
echo "Path: ${PLAN_ROOT}/${plan_id}"
elif [ -n "${plan_id}" ]; then
echo "Active plan pointer: ${plan_id} (directory not found — stale pointer)"
else
echo "No active plan set."
fi
# Use the resolver canonicalization policy without running plan selection.
slug_is_valid() {
case "$1" in
'') return 1 ;;
*[!A-Za-z0-9._-]*) return 1 ;;
[A-Za-z0-9_]*) return 0 ;;
esac
return 1
}
# Pure-sh backslash-to-forward-slash normalizer; result lands in $NORM_OUT.
# Windows-native coreutils builds (e.g. C:\Program Files\coreutils on PATH
# ahead of Git's usr/bin) canonicalize MSYS-style /c/... input to C:\-style
# backslash output. The containment prefix match below is written with forward
# slashes, so without this normalization every canonical pair mismatches and
# resolution silently fails. On POSIX systems paths contain no backslash and
# this is the identity. A literal backslash in a Unix filename normalizes to
# "/" and at worst fails containment — the safe direction. No subshell, no
# fork: plain parameter expansion in a loop.
norm_slashes() {
NORM_OUT=""
_ns_rest="$1"
while :; do
case "${_ns_rest}" in
*\\*)
NORM_OUT="${NORM_OUT}${_ns_rest%%\\*}/"
_ns_rest="${_ns_rest#*\\}"
;;
*)
NORM_OUT="${NORM_OUT}${_ns_rest}"
break
;;
esac
done
}
# Return true when a candidate path names the Microsoft Store WindowsApps
# directory. Store app aliases are not stable interpreter binaries and may
# present as executable while refusing script execution. Matching is
# case-insensitive and works before or after Windows slash normalization.
is_windowsapps_path() {
norm_slashes "$1"
case "${NORM_OUT}" in
[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]|\
[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]/*|\
*/[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]|\
*/[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]/*) return 0 ;;
esac
return 1
}
# Select only an interpreter path the caller explicitly trusted.
# PWF_TRUSTED_PYTHON is preferred; PYTHON_BIN remains a compatibility alias.
# PATH discovery is intentionally forbidden because resolver hooks can run in
# repositories that control PATH. Windows-native absolute paths are converted
# with Git Bash's fixed system cygpath, never a PATH-selected shim.
trusted_python() {
for _tp_candidate in "${PWF_TRUSTED_PYTHON:-}" "${PYTHON_BIN:-}"; do
[ -n "${_tp_candidate}" ] || continue
case "${_tp_candidate}" in
\\\\*|//*) continue ;;
[A-Za-z]:[\\/]*)
is_windowsapps_path "${_tp_candidate}" && continue
_tp_cygpath="/usr/bin/cygpath.exe"
[ -f "${_tp_cygpath}" ] && [ -x "${_tp_cygpath}" ] || continue
_tp_candidate="$("${_tp_cygpath}" -u "${_tp_candidate}" 2>/dev/null)" \
|| continue
;;
/*) ;;
*) continue ;;
esac
is_windowsapps_path "${_tp_candidate}" && continue
[ -f "${_tp_candidate}" ] || continue
[ -x "${_tp_candidate}" ] || continue
printf "%s\n" "${_tp_candidate}"
return 0
done
return 1
}
# Portable path canonicalizer. realpath first (Linux, modern coreutils),
# then readlink -f (older GNU), then an explicitly trusted Python interpreter.
# Prints the canonical absolute path on success; prints nothing and returns 1
# on a full miss so containment fails closed. No Python spawn on the happy
# path: realpath/readlink cover Linux, WSL, Git-Bash, and modern macOS.
canonicalize() {
target="$1"
if command -v realpath >/dev/null 2>&1; then
out="$(realpath "${target}" 2>/dev/null)" && [ -n "${out}" ] && {
printf "%s\n" "${out}"; return 0; }
fi
if command -v readlink >/dev/null 2>&1; then
out="$(readlink -f "${target}" 2>/dev/null)" && [ -n "${out}" ] && {
printf "%s\n" "${out}"; return 0; }
fi
_canonical_python="$(trusted_python)" || _canonical_python=""
if [ -n "${_canonical_python}" ]; then
out="$("${_canonical_python}" -I -c "import os,sys;print(os.path.realpath(sys.argv[1]))" "${target}" 2>/dev/null)" \
&& [ -n "${out}" ] && { printf "%s\n" "${out}"; return 0; }
fi
return 1
}
# Containment guard (security A1.3): a resolved plan dir must canonicalize to a
# path under the project root (the CWD the script runs from). A symlink inside
# a valid slug dir pointing at /etc or outside the workspace would otherwise let
# the hooks hash and inject an arbitrary file. On any violation we return 1 so
# the caller treats the candidate as unresolved and falls back safely.
#
# The root canonicalizes via the relative token "." rather than the $PWD
# string. On some Windows/MSYS setups (8.3 short names, the /tmp mount alias)
# realpath("$PWD") and realpath(relative-candidate) resolve through different
# code paths and land on differently-spelled-but-equal targets, so the prefix
# match below fails and resolution silently goes dark. "." resolves through
# the same physical-cwd path candidates already use (same fix inject-plan.sh
# received earlier; the resolver kept the $PWD form until now). Both sides are
# backslash-normalized before comparison for Windows-native canonicalizers.
# The root is computed once per run: the newest-mtime scan calls this guard
# per plan dir, and each canonicalize costs a process spawn on Windows.
#
# With a PWF_PLAN_ROOT pin (issue #212) containment is checked against THAT
# root instead of the cwd: candidates arrive ${PWF_PLAN_ROOT}/-prefixed, so
# both sides canonicalize through the same path spelling. Unpinned keeps the
# relative "." root — byte-identical to the legacy check.
ROOT_REAL=""
ROOT_REAL_SET=0
is_within_root() {
candidate="$1"
if [ "${ROOT_REAL_SET}" = "0" ]; then
ROOT_REAL="$(canonicalize "${PWF_ROOT_PIN:-.}")" || ROOT_REAL=""
norm_slashes "${ROOT_REAL}"
ROOT_REAL="${NORM_OUT}"
ROOT_REAL_SET=1
fi
# Canonicalize the candidate through its cwd-RELATIVE form whenever it
# lives under ${PWD}. The candidate string is built from ${PWD} (an MSYS
# long-form spelling), while the root canonicalizes from "." (the process
# cwd, which a caller may have set with an 8.3 short-form string). A
# Windows-native realpath does not unify those spellings, so canonicalizing
# both sides from the same cwd base is the only spelling-stable comparison.
# The emitted result keeps the original absolute candidate — only the
# containment check uses the relative form.
# Pinned resolution skips the rewrite: candidate and root then share the
# ${PWF_PLAN_ROOT} spelling, so both canonicalize directly from it.
if [ -n "${PWF_ROOT_PIN}" ]; then
check_target="${candidate}"
else
echo "No active plan set."
case "${candidate}" in
"${PWD}"/*) check_target=".${candidate#"${PWD}"}" ;;
*) check_target="${candidate}" ;;
esac
fi
cand_real="$(canonicalize "${check_target}")" || cand_real=""
norm_slashes "${cand_real}"
cand_real="${NORM_OUT}"
if [ -z "${ROOT_REAL}" ] || [ -z "${cand_real}" ]; then
# Slug validation blocks textual traversal, but only successful
# canonicalization can rule out a symlink/junction escape.
return 1
fi
case "${cand_real}" in
"${ROOT_REAL}"|"${ROOT_REAL}"/*) return 0 ;;
*) return 1 ;;
esac
}
# Each phase contributes at most one status. An explicit Status line wins
# over an inline heading marker. Fenced examples and unrelated sections are
# not phases. Keep this parser aligned with the PowerShell helper.
phase_status() {
awk '
function finish() {
if (phase) {
status = primary != "" ? primary : inline_status
if (status == "complete") complete++
else if (status == "in_progress") in_progress++
else if (status == "pending") pending++
}
phase = 0; primary = ""; inline_status = ""
}
{
line = $0
sub(/\r$/, "", line)
trimmed = line
sub(/^ */, "", trimmed)
if (line ~ /^ ? ? ?```/ || line ~ /^ ? ? ?~~~/) {
marker = substr(trimmed, 1, 1)
run = 0
while (substr(trimmed, run + 1, 1) == marker) run++
if (fence == "") { fence = marker; fence_length = run }
else if (marker == fence && run >= fence_length && substr(trimmed, run + 1) ~ /^[ \t]*$/) fence = ""
next
}
if (fence != "") next
if (line ~ /^ ? ? ?###[ \t]+(Phase|Fase|المرحلة|阶段|階段)[ \t]+[0-9]+([^0-9A-Za-z_]|$)/) {
finish(); phase = 1; total++
if (match(line, /\[(complete|in_progress|pending)\]/))
inline_status = substr(line, RSTART + 1, RLENGTH - 2)
} else if (line ~ /^ ? ? ?(###|##|#)([ \t]|$)/) {
finish()
} else if (phase && primary == "" && line ~ /^ ? ? ?(-[ \t]+)?\*\*(Status:|Estado:|الحالة:|状态:|狀態:)\*\*[ \t]+(complete|in_progress|pending)([ \t]|$)/) {
sub(/^ ? ? ?(-[ \t]+)?\*\*(Status:|Estado:|الحالة:|状态:|狀態:)\*\*[ \t]+/, "", line)
sub(/[ \t].*$/, "", line)
primary = line
}
}
END {
finish()
printf "%d/%d complete, %d in_progress, %d pending", complete, total, in_progress, pending
}
' < "$1"
}
current_active() {
if [ -f "${ACTIVE_FILE}" ] && is_within_root "${ACTIVE_FILE}"; then
_current="$(tr '\r' '\n' < "${ACTIVE_FILE}")"
# Windows editors and older PowerShell defaults can leave a UTF-8 BOM.
# Treat it as an encoding marker, not part of the shared plan slug.
_utf8_bom="$(printf '\357\273\277')"
case "${_current}" in
"${_utf8_bom}"*) _current="${_current#"${_utf8_bom}"}" ;;
esac
slug_is_valid "${_current}" && printf '%s\n' "${_current}"
fi
return 0
}
list_plans() {
if [ ! -d "${PLAN_ROOT}" ]; then
printf '%s\n' 'No planning directory found.'
return 0
fi
if ! is_within_root "${PLAN_ROOT}"; then
printf '%s\n' 'Error: planning directory is outside the project or cannot be verified.' >&2
return 1
fi
_active="$(current_active)"
_found=0
printf '%s\n' 'Available plans:'
for _dir in "${PLAN_ROOT}"/*; do
[ -d "${_dir}" ] || continue
_id="${_dir##*/}"
slug_is_valid "${_id}" || continue
is_within_root "${_dir}" || continue
_plan_file="${_dir}/task_plan.md"
[ -f "${_plan_file}" ] && [ -r "${_plan_file}" ] || continue
is_within_root "${_plan_file}" || continue
_status="$(phase_status "${_plan_file}")" || continue
_marker=''
if [ "${_id}" = "${_active}" ]; then _marker=' [active]'; fi
printf '%s\n' "- ${_id}${_marker} - ${_status}"
_found=1
done
if [ "${_found}" -eq 0 ]; then
printf '%s\n' 'No named plans found.'
else
printf '%s\n' '[active] marks the shared default pointer. Set PLAN_ID to pin a session.'
fi
}
if [ "$#" -gt 1 ]; then
printf '%s\n' 'Error: list plans or set PLAN_ID in separate calls.' >&2
exit 1
fi
case "${1:-}" in
--list|-l) list_plans; exit $? ;;
--help|-h)
printf '%s\n' 'Usage: set-active-plan.sh [--list|PLAN_ID]' \
'Lists saved named plans in the current project without selecting a plan.'
exit 0 ;;
esac
if [ "${1:-}" = '' ]; then
if [ -d "${PLAN_ROOT}" ] && ! is_within_root "${PLAN_ROOT}"; then
printf '%s\n' 'Error: planning directory is outside the project or cannot be verified.' >&2
exit 1
fi
plan_id="$(current_active)"
if [ -n "${plan_id}" ] && [ -d "${PLAN_ROOT}/${plan_id}" ] && is_within_root "${PLAN_ROOT}/${plan_id}"; then
printf '%s\n' "Active plan: ${plan_id}" "Path: ${PLAN_ROOT}/${plan_id}"
elif [ -n "${plan_id}" ]; then
printf '%s\n' "Active plan pointer: ${plan_id} (directory not found or outside project - stale pointer)"
else
printf '%s\n' 'No active plan set.'
fi
exit 0
fi
PLAN_ID="$1"
PLAN_DIR="${PLAN_ROOT}/${PLAN_ID}"
if [ ! -d "${PLAN_DIR}" ]; then
echo "Error: plan directory not found: ${PLAN_DIR}" >&2
echo "Run: init-session.sh \"${PLAN_ID}\" to create it, or check .planning/ for available plans." >&2
if ! slug_is_valid "${PLAN_ID}"; then
printf '%s\n' 'Error: invalid plan ID. Use a named directory under .planning.' >&2
exit 1
fi
mkdir -p "${PLAN_ROOT}"
printf "%s\n" "${PLAN_ID}" > "${ACTIVE_FILE}"
echo "Active plan set to: ${PLAN_ID}"
echo "Path: ${PLAN_DIR}"
echo ""
echo "To pin this terminal session only:"
echo " export PLAN_ID=${PLAN_ID}"
PLAN_DIR="${PLAN_ROOT}/${PLAN_ID}"
if [ ! -d "${PLAN_DIR}" ]; then
printf '%s\n' "Error: plan directory not found: ${PLAN_DIR}" \
"Run: init-session.sh \"${PLAN_ID}\" to create it, or use --list to see available plans." >&2
exit 1
fi
if ! is_within_root "${PLAN_ROOT}" || ! is_within_root "${PLAN_DIR}"; then
printf '%s\n' 'Error: plan directory is outside the project or cannot be verified.' >&2
exit 1
fi
# A pre-existing pointer must itself be a contained regular file before writing.
if { [ -e "${ACTIVE_FILE}" ] || [ -L "${ACTIVE_FILE}" ]; } &&
{ [ -L "${ACTIVE_FILE}" ] || [ ! -f "${ACTIVE_FILE}" ] || ! is_within_root "${ACTIVE_FILE}"; }; then
printf '%s\n' 'Error: active plan pointer is not a safe file inside the project.' >&2
exit 1
fi
# Replace the pointer atomically instead of truncating a possible hardlink.
# mktemp creates a private, exclusive file beside the destination.
temp_file="$(mktemp "${PLAN_ROOT}/.active_plan.XXXXXX")" || {
printf '%s\n' 'Error: could not create the active plan pointer.' >&2
exit 1
}
trap 'rm -f "${temp_file}"' EXIT
trap 'exit 1' HUP INT TERM
printf '%s\n' "${PLAN_ID}" > "${temp_file}"
if ! mv -f "${temp_file}" "${ACTIVE_FILE}"; then
printf '%s\n' 'Error: could not replace the active plan pointer.' >&2
exit 1
fi
trap - EXIT HUP INT TERM
printf '%s\n' "Active plan set to: ${PLAN_ID}" "Path: ${PLAN_DIR}" '' \
'To pin this terminal session only:' " export PLAN_ID=${PLAN_ID}"
+2 -2
View File
@@ -21,7 +21,7 @@ for _p in /usr/bin/python3 /usr/local/bin/python3 /opt/homebrew/bin/python3; do
[ -x "$_p" ] && { PYTHON="$_p"; break; }
done
[ -z "$PYTHON" ] && PYTHON=$(command -v python3 2>/dev/null || command -v python 2>/dev/null)
ERROR_MSG=$(printf '%s\n' "$INPUT" | $PYTHON -c "
ERROR_MSG=$(printf '%s\n' "$INPUT" | $PYTHON -I -X utf8 -c "
import sys, json
try:
data = json.load(sys.stdin)
@@ -33,7 +33,7 @@ except:
if [ -n "$ERROR_MSG" ]; then
CONTEXT="[planning-with-files] Error detected: ${ERROR_MSG}. Log this error in task_plan.md under Errors Encountered with the attempt number and resolution."
ESCAPED=$(printf '%s\n' "$CONTEXT" | $PYTHON -c "import sys,json; print(json.dumps(sys.stdin.read(), ensure_ascii=False))" 2>/dev/null || echo "\"\"")
ESCAPED=$(printf '%s\n' "$CONTEXT" | $PYTHON -I -X utf8 -c "import sys,json; print(json.dumps(sys.stdin.read(), ensure_ascii=False))" 2>/dev/null || echo "\"\"")
printf '%s\n' "{\"hookSpecificOutput\":{\"hookEventName\":\"ErrorOccurred\",\"additionalContext\":$ESCAPED}}"
else
echo '{}'
+1 -1
View File
@@ -31,7 +31,7 @@ for _p in /usr/bin/python3 /usr/local/bin/python3 /opt/homebrew/bin/python3; do
[ -x "$_p" ] && { PYTHON="$_p"; break; }
done
[ -z "$PYTHON" ] && PYTHON=$(command -v python3 2>/dev/null || command -v python 2>/dev/null)
ESCAPED=$(echo "$CONTEXT" | $PYTHON -c "import sys,json; print(json.dumps(sys.stdin.read(), ensure_ascii=False))" 2>/dev/null || echo "\"\"")
ESCAPED=$(echo "$CONTEXT" | $PYTHON -I -X utf8 -c "import sys,json; print(json.dumps(sys.stdin.read(), ensure_ascii=False))" 2>/dev/null || echo "\"\"")
printf '%s\n' "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"allow\",\"additionalContext\":$ESCAPED}}"
exit 0
+2 -2
View File
@@ -21,7 +21,7 @@ if [ -f "$PLAN_FILE" ]; then
# Plan exists: keep catchup zero-history, then read the plan header.
CATCHUP=""
if [ -n "$PYTHON" ] && [ -f "$SKILL_DIR/scripts/session-catchup.py" ]; then
CATCHUP=$($PYTHON "$SKILL_DIR/scripts/session-catchup.py" --no-history "$(pwd)" 2>/dev/null | head -100)
CATCHUP=$($PYTHON -I -X utf8 "$SKILL_DIR/scripts/session-catchup.py" --no-history "$(pwd)" 2>/dev/null | head -100)
fi
if [ -n "$CATCHUP" ]; then
@@ -43,7 +43,7 @@ if [ -z "$CONTEXT" ]; then
fi
# Escape context for JSON
ESCAPED=$(echo "$CONTEXT" | $PYTHON -c "import sys,json; print(json.dumps(sys.stdin.read(), ensure_ascii=False))" 2>/dev/null || echo "\"\"")
ESCAPED=$(echo "$CONTEXT" | $PYTHON -I -X utf8 -c "import sys,json; print(json.dumps(sys.stdin.read(), ensure_ascii=False))" 2>/dev/null || echo "\"\"")
printf '%s\n' "{\"hookSpecificOutput\":{\"hookEventName\":\"SessionStart\",\"additionalContext\":$ESCAPED}}"
exit 0
+4 -2
View File
@@ -29,7 +29,9 @@ jobs:
# on ubuntu so GNU-only regressions fail there too.
os: [ubuntu-latest, windows-latest, macos-latest]
runs-on: ${{ matrix.os }}
timeout-minutes: 15
# Windows runs the full suite through Git Bash and native PowerShell.
# Successful runs already approach 15 minutes; allow runner variance.
timeout-minutes: ${{ matrix.os == 'windows-latest' && 30 || 15 }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
@@ -45,7 +47,7 @@ jobs:
# sh.exe on windows-latest).
run: python -c "import shutil, sys; sys.exit(0 if shutil.which('sh') else 1)"
- name: Run pytest
run: python -m pytest tests/ -q
run: python -m pytest tests/ -q --durations=20
vitest:
name: vitest (Pi extension)
+9 -1
View File
@@ -2,7 +2,7 @@
name: planning-with-files
description: "Persistent file-based planning for multi-step AI-agent work. Keeps task_plan.md, findings.md, and progress.md on disk; lifecycle hooks inject selected project planning context. Automatic recovery reads project planning files only. Explicit session-catchup.py --metadata reads same-project local agent session records and emits aggregate counts only; --replay may emit bounded nonce-framed excerpts. Optional gated mode can request continuation only when the host supports it and never runs commands declared in Markdown. The skill has no network upload path. Use for research or work needing 5+ tool calls."
metadata:
version: "3.17.2"
version: "3.18.2"
hermes:
tags: [planning, long-running-tasks, context-engineering, workflow]
---
@@ -208,6 +208,14 @@ Helper scripts bundled with this Hermes skill:
The adapter plugin does not need any other script: plan resolution, injection, attestation checks, the completion gate and the `/pwf` initialization run in Python inside the plugin. The full canonical script surface (attestation helper, ledger, phase status, plan-doctor) ships with the canonical skill for hosts that dispatch shell hooks.
### List saved plans
To find a task before resuming it, run `sh "<skill-dir>/scripts/set-active-plan.sh" --list` or, in Windows PowerShell, `& "<skill-dir>/scripts/set-active-plan.ps1" -List`. Replace `<skill-dir>` with this installed skill directory and keep your current directory at the project root.
This read-only command lists named plans and phase progress under the current directory's `.planning/`. `[active]` marks the shared default pointer; it does not bind a session. Concurrent tasks still require each host's `PLAN_ID` or separate worktrees.
If your Hermes hub installation omits `.ps1` files, use `--list` with a POSIX shell or obtain the helper from the repository's `.hermes/skills/planning-with-files/scripts/` directory. The Hermes adapter itself does not depend on this helper.
## Advanced Topics
- **Manus Principles:** See [reference.md](reference.md)
@@ -0,0 +1,301 @@
# planning-with-files: set, display, or list the active plan pointer (PowerShell).
#
# Usage:
# .\set-active-plan.ps1 <plan_id> - pin .planning\.active_plan to plan_id
# .\set-active-plan.ps1 - print the current active plan (if any)
# .\set-active-plan.ps1 -List - list available named plans and phase counts
# .\set-active-plan.ps1 --list - equivalent to -List and -l
param(
[Parameter(Position = 0)]
[string]$PlanId = "",
[Alias('l', '-list')]
[switch]$List,
[Alias('h', '-help')]
[switch]$Help
)
$ProjectRoot = (Get-Location).Path
# Windows PowerShell 5.1 can silently change directories when -File inherits
# a cwd containing wildcard characters. Recover that physical cwd only for a
# direct invocation of this script; interactive Set-Location remains primary.
if ($PSVersionTable.PSVersion.Major -eq 5 -and
[System.Management.Automation.WildcardPattern]::ContainsWildcardCharacters([Environment]::CurrentDirectory)) {
$processArgs = [Environment]::GetCommandLineArgs()
for ($index = 0; $index -lt ($processArgs.Length - 1); $index++) {
if ($processArgs[$index] -ieq '-File') {
$entryScript = $processArgs[$index + 1]
if (-not [IO.Path]::IsPathRooted($entryScript)) {
$entryScript = Join-Path ([Environment]::CurrentDirectory) $entryScript
}
if ([IO.Path]::GetFullPath($entryScript) -eq [IO.Path]::GetFullPath($PSCommandPath)) {
$ProjectRoot = [Environment]::CurrentDirectory
}
break
}
}
}
$PlanRoot = Join-Path $ProjectRoot ".planning"
$ActiveFile = Join-Path $PlanRoot ".active_plan"
$script:IsWindowsHost = [Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT
# Resolve-Path can retain a junction's spelling. Like resolve-plan-dir.ps1,
# open the actual filesystem object before checking the project boundary.
# The Windows handle supports both directories and task_plan.md files.
function Get-FinalPath {
param([string]$Path)
if (-not ("PwfPlanListingNative" -as [type])) {
Add-Type -ErrorAction Stop -TypeDefinition @'
using System;
using System.ComponentModel;
using System.Runtime.InteropServices;
using System.Text;
using Microsoft.Win32.SafeHandles;
public static class PwfPlanListingNative {
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern SafeFileHandle CreateFileW(
string name, uint access, uint share, IntPtr security,
uint creation, uint flags, IntPtr template);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern uint GetFinalPathNameByHandleW(
SafeFileHandle handle, StringBuilder path, uint length, uint flags);
[DllImport("libc", SetLastError = true)]
private static extern IntPtr realpath(string path, IntPtr resolved);
[DllImport("libc")]
private static extern void free(IntPtr pointer);
public static string FinalPath(string path) {
if (Environment.OSVersion.Platform != PlatformID.Win32NT) {
IntPtr resolved = realpath(path, IntPtr.Zero);
if (resolved == IntPtr.Zero) throw new Win32Exception(Marshal.GetLastWin32Error());
try { return Marshal.PtrToStringAnsi(resolved); }
finally { free(resolved); }
}
// OPEN_EXISTING, FILE_SHARE_READ | WRITE | DELETE, BACKUP_SEMANTICS.
using (SafeFileHandle handle = CreateFileW(
path, 0, 7, IntPtr.Zero, 3, 0x02000000, IntPtr.Zero)) {
if (handle.IsInvalid) throw new Win32Exception(Marshal.GetLastWin32Error());
StringBuilder buffer = new StringBuilder(32768);
uint length = GetFinalPathNameByHandleW(handle, buffer, (uint)buffer.Capacity, 0);
if (length == 0 || length >= buffer.Capacity)
throw new Win32Exception(Marshal.GetLastWin32Error());
string result = buffer.ToString();
if (result.StartsWith(@"\\?\UNC\", StringComparison.OrdinalIgnoreCase))
return @"\\" + result.Substring(8);
if (result.StartsWith(@"\\?\", StringComparison.OrdinalIgnoreCase))
return result.Substring(4);
return result;
}
}
}
'@
}
return [PwfPlanListingNative]::FinalPath($Path)
}
function Test-WithinRoot {
param([string]$Path)
try {
$rootReal = (Get-FinalPath $ProjectRoot).TrimEnd('\', '/')
$pathReal = (Get-FinalPath $Path).TrimEnd('\', '/')
$comparison = [StringComparison]::Ordinal
if ($script:IsWindowsHost) { $comparison = [StringComparison]::OrdinalIgnoreCase }
return $pathReal.Equals($rootReal, $comparison) -or
$pathReal.StartsWith($rootReal + [IO.Path]::DirectorySeparatorChar, $comparison)
} catch {
return $false
}
}
function Test-ValidSlug {
param([string]$Name)
return $Name -cmatch '^[A-Za-z0-9_][A-Za-z0-9._-]*\z'
}
function Test-SafeActiveFile {
param([switch]$AllowLink)
$item = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
if (-not $item) { return $false }
# Reading may follow a verified in-project link; writing must not.
return -not $item.PSIsContainer -and
($AllowLink -or (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0)) -and
(Test-WithinRoot $ActiveFile)
}
function Get-CurrentActivePlan {
if (-not (Test-SafeActiveFile -AllowLink)) { return "" }
try {
$current = ([string](Get-Content -LiteralPath $ActiveFile -Raw -Encoding UTF8 -ErrorAction Stop)).Trim([char[]]"`r`n")
if (Test-ValidSlug $current) { return $current }
} catch { }
return ""
}
function Add-PhaseStatus {
param([hashtable]$Counts, $Phase)
if ($null -eq $Phase) { return }
$status = $Phase.Inline
if ($Phase.Primary) { $status = $Phase.Primary }
if ($status) { $Counts[$status]++ }
}
function Get-PhaseStatus {
param([string]$PlanFile)
$counts = @{ total = 0; complete = 0; in_progress = 0; pending = 0 }
$phase = $null
$fence = ""
$fenceLength = 0
# The shipped translations keep status identifiers in English. Unicode
# escapes preserve Windows PowerShell 5.1 compatibility without a BOM.
$phaseLabel = '(?:Phase|Fase|\u0627\u0644\u0645\u0631\u062d\u0644\u0629|\u9636\u6bb5|\u968e\u6bb5)'
$statusLabel = '(?:Status:|Estado:|\u0627\u0644\u062d\u0627\u0644\u0629:|\u72b6\u6001\uff1a|\u72c0\u614b\uff1a)'
# Read once; an empty file is a valid plan with zero phases.
$content = [string](Get-Content -LiteralPath $PlanFile -Raw -Encoding UTF8 -ErrorAction Stop)
foreach ($line in ($content -split '\r?\n')) {
if ($fence) {
$closing = '^ {0,3}' + [regex]::Escape($fence) + '{' + $fenceLength + ',}[ \t]*$'
if ($line -match $closing) { $fence = "" }
continue
}
if ($line -match '^ {0,3}(`{3,}|~{3,})') {
$fence = $Matches[1].Substring(0, 1)
$fenceLength = $Matches[1].Length
continue
}
if ($line -cmatch ('^ {0,3}###[ \t]+' + $phaseLabel + '[ \t]+[0-9]+(?:[^0-9A-Za-z_]|$)')) {
Add-PhaseStatus $counts $phase
$phase = @{ Primary = ""; Inline = "" }
$counts.total++
if ($line -cmatch '\[(complete|in_progress|pending)\]') {
$phase.Inline = $Matches[1]
}
continue
}
if ($line -match '^ {0,3}#{1,3}(?:[ \t]+|$)') {
Add-PhaseStatus $counts $phase
$phase = $null
continue
}
if ($null -ne $phase -and -not $phase.Primary -and
$line -cmatch ('^ {0,3}(?:-[ \t]+)?\*\*' + $statusLabel + '\*\*[ \t]+(complete|in_progress|pending)(?:[ \t]|$)')) {
$phase.Primary = $Matches[1]
}
}
Add-PhaseStatus $counts $phase
return "$($counts.complete)/$($counts.total) complete, $($counts.in_progress) in_progress, $($counts.pending) pending"
}
function Show-PlanList {
if (-not (Test-Path -LiteralPath $PlanRoot -PathType Container)) {
Write-Output "No planning directory found."
return
}
if (-not (Test-WithinRoot $PlanRoot)) {
Write-Error "Error: planning directory is outside the project or cannot be verified."
exit 1
}
$active = Get-CurrentActivePlan
Write-Output "Available plans:"
Write-Output "[active] marks the shared .active_plan pointer; listing does not bind this session."
$found = $false
foreach ($plan in (Get-ChildItem -LiteralPath $PlanRoot -Directory -ErrorAction SilentlyContinue | Sort-Object Name)) {
if (-not (Test-ValidSlug $plan.Name) -or -not (Test-WithinRoot $plan.FullName)) { continue }
$planFile = Join-Path $plan.FullName "task_plan.md"
if (-not (Test-Path -LiteralPath $planFile -PathType Leaf) -or -not (Test-WithinRoot $planFile)) { continue }
try { $status = Get-PhaseStatus $planFile } catch { continue }
$marker = ""
if ($plan.Name -ceq $active) { $marker = " [active]" }
Write-Output "- $($plan.Name)$marker - $status"
$found = $true
}
if (-not $found) { Write-Output "No named plans found." }
}
if ($Help -or $PlanId -eq "--help" -or $PlanId -eq "-h") {
Write-Output "Usage: set-active-plan.ps1 [-List|-l|--list|PLAN_ID]"
exit 0
}
if ($List -or $PlanId -eq "--list" -or $PlanId -eq "-l") {
if ($List -and $PlanId) {
Write-Error "Error: list plans or set PLAN_ID in separate calls."
exit 1
}
Show-PlanList
exit 0
}
if ($PlanId -eq "") {
$current = ""
if ((Test-Path -LiteralPath $PlanRoot -PathType Container) -and -not (Test-WithinRoot $PlanRoot)) {
Write-Error "Error: planning directory is outside the project or cannot be verified."
exit 1
}
if (Test-WithinRoot $PlanRoot) { $current = Get-CurrentActivePlan }
if ($current) {
$planDir = Join-Path $PlanRoot $current
if ((Test-Path -LiteralPath $planDir -PathType Container) -and (Test-WithinRoot $planDir)) {
Write-Output "Active plan: $current"
Write-Output "Path: $planDir"
} else {
Write-Output "Active plan pointer: $current (directory not found or outside project - stale pointer)"
}
} else {
Write-Output "No active plan set."
}
exit 0
}
if (-not (Test-ValidSlug $PlanId)) {
Write-Error "Error: invalid plan ID. Use letters, numbers, underscores, dots, or hyphens; start with a letter, number, or underscore."
exit 1
}
$PlanDir = Join-Path $PlanRoot $PlanId
if (-not (Test-Path -LiteralPath $PlanDir -PathType Container)) {
Write-Error "Error: plan directory not found: $PlanDir"
Write-Error "Run: init-session.sh `"$PlanId`" to create it, or check .planning\ for available plans."
exit 1
}
if (-not (Test-WithinRoot $PlanRoot) -or -not (Test-WithinRoot $PlanDir)) {
Write-Error "Error: plan directory must remain within the project."
exit 1
}
$activeItem = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
if ($activeItem -and -not (Test-SafeActiveFile)) {
Write-Error "Error: the active plan pointer must be a regular file within the project."
exit 1
}
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
# Replace the directory entry instead of truncating an existing inode: a
# hardlinked pointer must not overwrite another file. The exclusive temporary
# file lives beside the pointer, keeping replacement on the same filesystem.
$tempFile = Join-Path $PlanRoot ('.active_plan.' + [guid]::NewGuid().ToString('N') + '.tmp')
$createdTemp = $false
try {
$stream = [IO.File]::Open($tempFile, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
$createdTemp = $true
try {
$bytes = $utf8NoBom.GetBytes($PlanId)
$stream.Write($bytes, 0, $bytes.Length)
$stream.Flush()
} finally { $stream.Dispose() }
if ($activeItem) {
[IO.File]::Replace($tempFile, $ActiveFile, [NullString]::Value)
} else {
[IO.File]::Move($tempFile, $ActiveFile)
}
} catch {
Write-Error "Error: could not set the active plan pointer: $($_.Exception.Message)"
exit 1
} finally {
if ($createdTemp -and (Test-Path -LiteralPath $tempFile)) {
Remove-Item -LiteralPath $tempFile -Force -ErrorAction SilentlyContinue
}
}
Write-Output "Active plan set to: $PlanId"
Write-Output "Path: $PlanDir"
Write-Output ""
Write-Output "To pin this terminal session only:"
Write-Output "`$env:PLAN_ID = '$PlanId'"
@@ -0,0 +1,335 @@
#!/bin/sh
# List saved named plans, show the shared pointer, or change that pointer.
# Usage: set-active-plan.sh [--list|-l|PLAN_ID]
# Operates on the current project; listing never binds a host or injects data.
set -eu
PLAN_ROOT="${PWD}/.planning"
ACTIVE_FILE="${PLAN_ROOT}/.active_plan"
PWF_ROOT_PIN=""
# Use the resolver canonicalization policy without running plan selection.
slug_is_valid() {
case "$1" in
'') return 1 ;;
*[!A-Za-z0-9._-]*) return 1 ;;
[A-Za-z0-9_]*) return 0 ;;
esac
return 1
}
# Pure-sh backslash-to-forward-slash normalizer; result lands in $NORM_OUT.
# Windows-native coreutils builds (e.g. C:\Program Files\coreutils on PATH
# ahead of Git's usr/bin) canonicalize MSYS-style /c/... input to C:\-style
# backslash output. The containment prefix match below is written with forward
# slashes, so without this normalization every canonical pair mismatches and
# resolution silently fails. On POSIX systems paths contain no backslash and
# this is the identity. A literal backslash in a Unix filename normalizes to
# "/" and at worst fails containment — the safe direction. No subshell, no
# fork: plain parameter expansion in a loop.
norm_slashes() {
NORM_OUT=""
_ns_rest="$1"
while :; do
case "${_ns_rest}" in
*\\*)
NORM_OUT="${NORM_OUT}${_ns_rest%%\\*}/"
_ns_rest="${_ns_rest#*\\}"
;;
*)
NORM_OUT="${NORM_OUT}${_ns_rest}"
break
;;
esac
done
}
# Return true when a candidate path names the Microsoft Store WindowsApps
# directory. Store app aliases are not stable interpreter binaries and may
# present as executable while refusing script execution. Matching is
# case-insensitive and works before or after Windows slash normalization.
is_windowsapps_path() {
norm_slashes "$1"
case "${NORM_OUT}" in
[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]|\
[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]/*|\
*/[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]|\
*/[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]/*) return 0 ;;
esac
return 1
}
# Select only an interpreter path the caller explicitly trusted.
# PWF_TRUSTED_PYTHON is preferred; PYTHON_BIN remains a compatibility alias.
# PATH discovery is intentionally forbidden because resolver hooks can run in
# repositories that control PATH. Windows-native absolute paths are converted
# with Git Bash's fixed system cygpath, never a PATH-selected shim.
trusted_python() {
for _tp_candidate in "${PWF_TRUSTED_PYTHON:-}" "${PYTHON_BIN:-}"; do
[ -n "${_tp_candidate}" ] || continue
case "${_tp_candidate}" in
\\\\*|//*) continue ;;
[A-Za-z]:[\\/]*)
is_windowsapps_path "${_tp_candidate}" && continue
_tp_cygpath="/usr/bin/cygpath.exe"
[ -f "${_tp_cygpath}" ] && [ -x "${_tp_cygpath}" ] || continue
_tp_candidate="$("${_tp_cygpath}" -u "${_tp_candidate}" 2>/dev/null)" \
|| continue
;;
/*) ;;
*) continue ;;
esac
is_windowsapps_path "${_tp_candidate}" && continue
[ -f "${_tp_candidate}" ] || continue
[ -x "${_tp_candidate}" ] || continue
printf "%s\n" "${_tp_candidate}"
return 0
done
return 1
}
# Portable path canonicalizer. realpath first (Linux, modern coreutils),
# then readlink -f (older GNU), then an explicitly trusted Python interpreter.
# Prints the canonical absolute path on success; prints nothing and returns 1
# on a full miss so containment fails closed. No Python spawn on the happy
# path: realpath/readlink cover Linux, WSL, Git-Bash, and modern macOS.
canonicalize() {
target="$1"
if command -v realpath >/dev/null 2>&1; then
out="$(realpath "${target}" 2>/dev/null)" && [ -n "${out}" ] && {
printf "%s\n" "${out}"; return 0; }
fi
if command -v readlink >/dev/null 2>&1; then
out="$(readlink -f "${target}" 2>/dev/null)" && [ -n "${out}" ] && {
printf "%s\n" "${out}"; return 0; }
fi
_canonical_python="$(trusted_python)" || _canonical_python=""
if [ -n "${_canonical_python}" ]; then
out="$("${_canonical_python}" -I -c "import os,sys;print(os.path.realpath(sys.argv[1]))" "${target}" 2>/dev/null)" \
&& [ -n "${out}" ] && { printf "%s\n" "${out}"; return 0; }
fi
return 1
}
# Containment guard (security A1.3): a resolved plan dir must canonicalize to a
# path under the project root (the CWD the script runs from). A symlink inside
# a valid slug dir pointing at /etc or outside the workspace would otherwise let
# the hooks hash and inject an arbitrary file. On any violation we return 1 so
# the caller treats the candidate as unresolved and falls back safely.
#
# The root canonicalizes via the relative token "." rather than the $PWD
# string. On some Windows/MSYS setups (8.3 short names, the /tmp mount alias)
# realpath("$PWD") and realpath(relative-candidate) resolve through different
# code paths and land on differently-spelled-but-equal targets, so the prefix
# match below fails and resolution silently goes dark. "." resolves through
# the same physical-cwd path candidates already use (same fix inject-plan.sh
# received earlier; the resolver kept the $PWD form until now). Both sides are
# backslash-normalized before comparison for Windows-native canonicalizers.
# The root is computed once per run: the newest-mtime scan calls this guard
# per plan dir, and each canonicalize costs a process spawn on Windows.
#
# With a PWF_PLAN_ROOT pin (issue #212) containment is checked against THAT
# root instead of the cwd: candidates arrive ${PWF_PLAN_ROOT}/-prefixed, so
# both sides canonicalize through the same path spelling. Unpinned keeps the
# relative "." root — byte-identical to the legacy check.
ROOT_REAL=""
ROOT_REAL_SET=0
is_within_root() {
candidate="$1"
if [ "${ROOT_REAL_SET}" = "0" ]; then
ROOT_REAL="$(canonicalize "${PWF_ROOT_PIN:-.}")" || ROOT_REAL=""
norm_slashes "${ROOT_REAL}"
ROOT_REAL="${NORM_OUT}"
ROOT_REAL_SET=1
fi
# Canonicalize the candidate through its cwd-RELATIVE form whenever it
# lives under ${PWD}. The candidate string is built from ${PWD} (an MSYS
# long-form spelling), while the root canonicalizes from "." (the process
# cwd, which a caller may have set with an 8.3 short-form string). A
# Windows-native realpath does not unify those spellings, so canonicalizing
# both sides from the same cwd base is the only spelling-stable comparison.
# The emitted result keeps the original absolute candidate — only the
# containment check uses the relative form.
# Pinned resolution skips the rewrite: candidate and root then share the
# ${PWF_PLAN_ROOT} spelling, so both canonicalize directly from it.
if [ -n "${PWF_ROOT_PIN}" ]; then
check_target="${candidate}"
else
case "${candidate}" in
"${PWD}"/*) check_target=".${candidate#"${PWD}"}" ;;
*) check_target="${candidate}" ;;
esac
fi
cand_real="$(canonicalize "${check_target}")" || cand_real=""
norm_slashes "${cand_real}"
cand_real="${NORM_OUT}"
if [ -z "${ROOT_REAL}" ] || [ -z "${cand_real}" ]; then
# Slug validation blocks textual traversal, but only successful
# canonicalization can rule out a symlink/junction escape.
return 1
fi
case "${cand_real}" in
"${ROOT_REAL}"|"${ROOT_REAL}"/*) return 0 ;;
*) return 1 ;;
esac
}
# Each phase contributes at most one status. An explicit Status line wins
# over an inline heading marker. Fenced examples and unrelated sections are
# not phases. Keep this parser aligned with the PowerShell helper.
phase_status() {
awk '
function finish() {
if (phase) {
status = primary != "" ? primary : inline_status
if (status == "complete") complete++
else if (status == "in_progress") in_progress++
else if (status == "pending") pending++
}
phase = 0; primary = ""; inline_status = ""
}
{
line = $0
sub(/\r$/, "", line)
trimmed = line
sub(/^ */, "", trimmed)
if (line ~ /^ ? ? ?```/ || line ~ /^ ? ? ?~~~/) {
marker = substr(trimmed, 1, 1)
run = 0
while (substr(trimmed, run + 1, 1) == marker) run++
if (fence == "") { fence = marker; fence_length = run }
else if (marker == fence && run >= fence_length && substr(trimmed, run + 1) ~ /^[ \t]*$/) fence = ""
next
}
if (fence != "") next
if (line ~ /^ ? ? ?###[ \t]+(Phase|Fase|المرحلة|阶段|階段)[ \t]+[0-9]+([^0-9A-Za-z_]|$)/) {
finish(); phase = 1; total++
if (match(line, /\[(complete|in_progress|pending)\]/))
inline_status = substr(line, RSTART + 1, RLENGTH - 2)
} else if (line ~ /^ ? ? ?(###|##|#)([ \t]|$)/) {
finish()
} else if (phase && primary == "" && line ~ /^ ? ? ?(-[ \t]+)?\*\*(Status:|Estado:|الحالة:|状态:|狀態:)\*\*[ \t]+(complete|in_progress|pending)([ \t]|$)/) {
sub(/^ ? ? ?(-[ \t]+)?\*\*(Status:|Estado:|الحالة:|状态:|狀態:)\*\*[ \t]+/, "", line)
sub(/[ \t].*$/, "", line)
primary = line
}
}
END {
finish()
printf "%d/%d complete, %d in_progress, %d pending", complete, total, in_progress, pending
}
' < "$1"
}
current_active() {
if [ -f "${ACTIVE_FILE}" ] && is_within_root "${ACTIVE_FILE}"; then
_current="$(tr '\r' '\n' < "${ACTIVE_FILE}")"
# Windows editors and older PowerShell defaults can leave a UTF-8 BOM.
# Treat it as an encoding marker, not part of the shared plan slug.
_utf8_bom="$(printf '\357\273\277')"
case "${_current}" in
"${_utf8_bom}"*) _current="${_current#"${_utf8_bom}"}" ;;
esac
slug_is_valid "${_current}" && printf '%s\n' "${_current}"
fi
return 0
}
list_plans() {
if [ ! -d "${PLAN_ROOT}" ]; then
printf '%s\n' 'No planning directory found.'
return 0
fi
if ! is_within_root "${PLAN_ROOT}"; then
printf '%s\n' 'Error: planning directory is outside the project or cannot be verified.' >&2
return 1
fi
_active="$(current_active)"
_found=0
printf '%s\n' 'Available plans:'
for _dir in "${PLAN_ROOT}"/*; do
[ -d "${_dir}" ] || continue
_id="${_dir##*/}"
slug_is_valid "${_id}" || continue
is_within_root "${_dir}" || continue
_plan_file="${_dir}/task_plan.md"
[ -f "${_plan_file}" ] && [ -r "${_plan_file}" ] || continue
is_within_root "${_plan_file}" || continue
_status="$(phase_status "${_plan_file}")" || continue
_marker=''
if [ "${_id}" = "${_active}" ]; then _marker=' [active]'; fi
printf '%s\n' "- ${_id}${_marker} - ${_status}"
_found=1
done
if [ "${_found}" -eq 0 ]; then
printf '%s\n' 'No named plans found.'
else
printf '%s\n' '[active] marks the shared default pointer. Set PLAN_ID to pin a session.'
fi
}
if [ "$#" -gt 1 ]; then
printf '%s\n' 'Error: list plans or set PLAN_ID in separate calls.' >&2
exit 1
fi
case "${1:-}" in
--list|-l) list_plans; exit $? ;;
--help|-h)
printf '%s\n' 'Usage: set-active-plan.sh [--list|PLAN_ID]' \
'Lists saved named plans in the current project without selecting a plan.'
exit 0 ;;
esac
if [ "${1:-}" = '' ]; then
if [ -d "${PLAN_ROOT}" ] && ! is_within_root "${PLAN_ROOT}"; then
printf '%s\n' 'Error: planning directory is outside the project or cannot be verified.' >&2
exit 1
fi
plan_id="$(current_active)"
if [ -n "${plan_id}" ] && [ -d "${PLAN_ROOT}/${plan_id}" ] && is_within_root "${PLAN_ROOT}/${plan_id}"; then
printf '%s\n' "Active plan: ${plan_id}" "Path: ${PLAN_ROOT}/${plan_id}"
elif [ -n "${plan_id}" ]; then
printf '%s\n' "Active plan pointer: ${plan_id} (directory not found or outside project - stale pointer)"
else
printf '%s\n' 'No active plan set.'
fi
exit 0
fi
PLAN_ID="$1"
if ! slug_is_valid "${PLAN_ID}"; then
printf '%s\n' 'Error: invalid plan ID. Use a named directory under .planning.' >&2
exit 1
fi
PLAN_DIR="${PLAN_ROOT}/${PLAN_ID}"
if [ ! -d "${PLAN_DIR}" ]; then
printf '%s\n' "Error: plan directory not found: ${PLAN_DIR}" \
"Run: init-session.sh \"${PLAN_ID}\" to create it, or use --list to see available plans." >&2
exit 1
fi
if ! is_within_root "${PLAN_ROOT}" || ! is_within_root "${PLAN_DIR}"; then
printf '%s\n' 'Error: plan directory is outside the project or cannot be verified.' >&2
exit 1
fi
# A pre-existing pointer must itself be a contained regular file before writing.
if { [ -e "${ACTIVE_FILE}" ] || [ -L "${ACTIVE_FILE}" ]; } &&
{ [ -L "${ACTIVE_FILE}" ] || [ ! -f "${ACTIVE_FILE}" ] || ! is_within_root "${ACTIVE_FILE}"; }; then
printf '%s\n' 'Error: active plan pointer is not a safe file inside the project.' >&2
exit 1
fi
# Replace the pointer atomically instead of truncating a possible hardlink.
# mktemp creates a private, exclusive file beside the destination.
temp_file="$(mktemp "${PLAN_ROOT}/.active_plan.XXXXXX")" || {
printf '%s\n' 'Error: could not create the active plan pointer.' >&2
exit 1
}
trap 'rm -f "${temp_file}"' EXIT
trap 'exit 1' HUP INT TERM
printf '%s\n' "${PLAN_ID}" > "${temp_file}"
if ! mv -f "${temp_file}" "${ACTIVE_FILE}"; then
printf '%s\n' 'Error: could not replace the active plan pointer.' >&2
exit 1
fi
trap - EXIT HUP INT TERM
printf '%s\n' "Active plan set to: ${PLAN_ID}" "Path: ${PLAN_DIR}" '' \
'To pin this terminal session only:' " export PLAN_ID=${PLAN_ID}"
@@ -187,6 +187,8 @@ AFTER 3 FAILURES: Escalate to User
## Scripts
Kiro keeps its plan in `.kiro/plan/`. The canonical `set-active-plan` listing helper reads named plans under `.planning/` and does not list or switch this Kiro plan.
Helper scripts (under `assets/scripts/`):
- `assets/scripts/bootstrap.sh` — Idempotent workspace bootstrap. Creates `.kiro/plan/` and `.kiro/steering/planning-context.md`.
@@ -36,7 +36,7 @@ hooks:
- type: command
command: "SH=\"\"; for c in \"${PWF_SCRIPT_DIR}/skill-hook.sh\" \"${CLAUDE_SKILL_DIR}/scripts/skill-hook.sh\" \"$HOME/.mastracode/skills/planning-with-files/scripts/skill-hook.sh\" \"$HOME/.claude/skills/planning-with-files/scripts/skill-hook.sh\" \"$HOME/.claude/plugins/marketplaces/planning-with-files/scripts/skill-hook.sh\"; do [ -f \"$c\" ] && { SH=\"$c\"; break; }; done; [ -n \"$SH\" ] && sh \"$SH\" --event=precompact; exit 0"
metadata:
version: "3.17.2"
version: "3.18.2"
---
@@ -212,6 +212,12 @@ Helper scripts for automation:
- `scripts/check-complete.sh` — Verify all phases complete
- `scripts/session-catchup.py`: Explicit same-project session-record aggregation or bounded replay (`--metadata` / `--replay`); bare invocation does not access host history
### List saved plans
To find a task before resuming it, run `sh "<skill-dir>/scripts/set-active-plan.sh" --list` or, in Windows PowerShell, `& "<skill-dir>/scripts/set-active-plan.ps1" -List`. Replace `<skill-dir>` with this installed skill directory and keep your current directory at the project root.
This read-only command lists named plans and phase progress under the current directory's `.planning/`. `[active]` marks the shared default pointer; it does not bind a session. Concurrent tasks still require each host's `PLAN_ID` or separate worktrees.
## Advanced Topics
- **Manus Principles:** See [references/reference.md](references/reference.md)
@@ -0,0 +1,301 @@
# planning-with-files: set, display, or list the active plan pointer (PowerShell).
#
# Usage:
# .\set-active-plan.ps1 <plan_id> - pin .planning\.active_plan to plan_id
# .\set-active-plan.ps1 - print the current active plan (if any)
# .\set-active-plan.ps1 -List - list available named plans and phase counts
# .\set-active-plan.ps1 --list - equivalent to -List and -l
param(
[Parameter(Position = 0)]
[string]$PlanId = "",
[Alias('l', '-list')]
[switch]$List,
[Alias('h', '-help')]
[switch]$Help
)
$ProjectRoot = (Get-Location).Path
# Windows PowerShell 5.1 can silently change directories when -File inherits
# a cwd containing wildcard characters. Recover that physical cwd only for a
# direct invocation of this script; interactive Set-Location remains primary.
if ($PSVersionTable.PSVersion.Major -eq 5 -and
[System.Management.Automation.WildcardPattern]::ContainsWildcardCharacters([Environment]::CurrentDirectory)) {
$processArgs = [Environment]::GetCommandLineArgs()
for ($index = 0; $index -lt ($processArgs.Length - 1); $index++) {
if ($processArgs[$index] -ieq '-File') {
$entryScript = $processArgs[$index + 1]
if (-not [IO.Path]::IsPathRooted($entryScript)) {
$entryScript = Join-Path ([Environment]::CurrentDirectory) $entryScript
}
if ([IO.Path]::GetFullPath($entryScript) -eq [IO.Path]::GetFullPath($PSCommandPath)) {
$ProjectRoot = [Environment]::CurrentDirectory
}
break
}
}
}
$PlanRoot = Join-Path $ProjectRoot ".planning"
$ActiveFile = Join-Path $PlanRoot ".active_plan"
$script:IsWindowsHost = [Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT
# Resolve-Path can retain a junction's spelling. Like resolve-plan-dir.ps1,
# open the actual filesystem object before checking the project boundary.
# The Windows handle supports both directories and task_plan.md files.
function Get-FinalPath {
param([string]$Path)
if (-not ("PwfPlanListingNative" -as [type])) {
Add-Type -ErrorAction Stop -TypeDefinition @'
using System;
using System.ComponentModel;
using System.Runtime.InteropServices;
using System.Text;
using Microsoft.Win32.SafeHandles;
public static class PwfPlanListingNative {
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern SafeFileHandle CreateFileW(
string name, uint access, uint share, IntPtr security,
uint creation, uint flags, IntPtr template);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern uint GetFinalPathNameByHandleW(
SafeFileHandle handle, StringBuilder path, uint length, uint flags);
[DllImport("libc", SetLastError = true)]
private static extern IntPtr realpath(string path, IntPtr resolved);
[DllImport("libc")]
private static extern void free(IntPtr pointer);
public static string FinalPath(string path) {
if (Environment.OSVersion.Platform != PlatformID.Win32NT) {
IntPtr resolved = realpath(path, IntPtr.Zero);
if (resolved == IntPtr.Zero) throw new Win32Exception(Marshal.GetLastWin32Error());
try { return Marshal.PtrToStringAnsi(resolved); }
finally { free(resolved); }
}
// OPEN_EXISTING, FILE_SHARE_READ | WRITE | DELETE, BACKUP_SEMANTICS.
using (SafeFileHandle handle = CreateFileW(
path, 0, 7, IntPtr.Zero, 3, 0x02000000, IntPtr.Zero)) {
if (handle.IsInvalid) throw new Win32Exception(Marshal.GetLastWin32Error());
StringBuilder buffer = new StringBuilder(32768);
uint length = GetFinalPathNameByHandleW(handle, buffer, (uint)buffer.Capacity, 0);
if (length == 0 || length >= buffer.Capacity)
throw new Win32Exception(Marshal.GetLastWin32Error());
string result = buffer.ToString();
if (result.StartsWith(@"\\?\UNC\", StringComparison.OrdinalIgnoreCase))
return @"\\" + result.Substring(8);
if (result.StartsWith(@"\\?\", StringComparison.OrdinalIgnoreCase))
return result.Substring(4);
return result;
}
}
}
'@
}
return [PwfPlanListingNative]::FinalPath($Path)
}
function Test-WithinRoot {
param([string]$Path)
try {
$rootReal = (Get-FinalPath $ProjectRoot).TrimEnd('\', '/')
$pathReal = (Get-FinalPath $Path).TrimEnd('\', '/')
$comparison = [StringComparison]::Ordinal
if ($script:IsWindowsHost) { $comparison = [StringComparison]::OrdinalIgnoreCase }
return $pathReal.Equals($rootReal, $comparison) -or
$pathReal.StartsWith($rootReal + [IO.Path]::DirectorySeparatorChar, $comparison)
} catch {
return $false
}
}
function Test-ValidSlug {
param([string]$Name)
return $Name -cmatch '^[A-Za-z0-9_][A-Za-z0-9._-]*\z'
}
function Test-SafeActiveFile {
param([switch]$AllowLink)
$item = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
if (-not $item) { return $false }
# Reading may follow a verified in-project link; writing must not.
return -not $item.PSIsContainer -and
($AllowLink -or (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0)) -and
(Test-WithinRoot $ActiveFile)
}
function Get-CurrentActivePlan {
if (-not (Test-SafeActiveFile -AllowLink)) { return "" }
try {
$current = ([string](Get-Content -LiteralPath $ActiveFile -Raw -Encoding UTF8 -ErrorAction Stop)).Trim([char[]]"`r`n")
if (Test-ValidSlug $current) { return $current }
} catch { }
return ""
}
function Add-PhaseStatus {
param([hashtable]$Counts, $Phase)
if ($null -eq $Phase) { return }
$status = $Phase.Inline
if ($Phase.Primary) { $status = $Phase.Primary }
if ($status) { $Counts[$status]++ }
}
function Get-PhaseStatus {
param([string]$PlanFile)
$counts = @{ total = 0; complete = 0; in_progress = 0; pending = 0 }
$phase = $null
$fence = ""
$fenceLength = 0
# The shipped translations keep status identifiers in English. Unicode
# escapes preserve Windows PowerShell 5.1 compatibility without a BOM.
$phaseLabel = '(?:Phase|Fase|\u0627\u0644\u0645\u0631\u062d\u0644\u0629|\u9636\u6bb5|\u968e\u6bb5)'
$statusLabel = '(?:Status:|Estado:|\u0627\u0644\u062d\u0627\u0644\u0629:|\u72b6\u6001\uff1a|\u72c0\u614b\uff1a)'
# Read once; an empty file is a valid plan with zero phases.
$content = [string](Get-Content -LiteralPath $PlanFile -Raw -Encoding UTF8 -ErrorAction Stop)
foreach ($line in ($content -split '\r?\n')) {
if ($fence) {
$closing = '^ {0,3}' + [regex]::Escape($fence) + '{' + $fenceLength + ',}[ \t]*$'
if ($line -match $closing) { $fence = "" }
continue
}
if ($line -match '^ {0,3}(`{3,}|~{3,})') {
$fence = $Matches[1].Substring(0, 1)
$fenceLength = $Matches[1].Length
continue
}
if ($line -cmatch ('^ {0,3}###[ \t]+' + $phaseLabel + '[ \t]+[0-9]+(?:[^0-9A-Za-z_]|$)')) {
Add-PhaseStatus $counts $phase
$phase = @{ Primary = ""; Inline = "" }
$counts.total++
if ($line -cmatch '\[(complete|in_progress|pending)\]') {
$phase.Inline = $Matches[1]
}
continue
}
if ($line -match '^ {0,3}#{1,3}(?:[ \t]+|$)') {
Add-PhaseStatus $counts $phase
$phase = $null
continue
}
if ($null -ne $phase -and -not $phase.Primary -and
$line -cmatch ('^ {0,3}(?:-[ \t]+)?\*\*' + $statusLabel + '\*\*[ \t]+(complete|in_progress|pending)(?:[ \t]|$)')) {
$phase.Primary = $Matches[1]
}
}
Add-PhaseStatus $counts $phase
return "$($counts.complete)/$($counts.total) complete, $($counts.in_progress) in_progress, $($counts.pending) pending"
}
function Show-PlanList {
if (-not (Test-Path -LiteralPath $PlanRoot -PathType Container)) {
Write-Output "No planning directory found."
return
}
if (-not (Test-WithinRoot $PlanRoot)) {
Write-Error "Error: planning directory is outside the project or cannot be verified."
exit 1
}
$active = Get-CurrentActivePlan
Write-Output "Available plans:"
Write-Output "[active] marks the shared .active_plan pointer; listing does not bind this session."
$found = $false
foreach ($plan in (Get-ChildItem -LiteralPath $PlanRoot -Directory -ErrorAction SilentlyContinue | Sort-Object Name)) {
if (-not (Test-ValidSlug $plan.Name) -or -not (Test-WithinRoot $plan.FullName)) { continue }
$planFile = Join-Path $plan.FullName "task_plan.md"
if (-not (Test-Path -LiteralPath $planFile -PathType Leaf) -or -not (Test-WithinRoot $planFile)) { continue }
try { $status = Get-PhaseStatus $planFile } catch { continue }
$marker = ""
if ($plan.Name -ceq $active) { $marker = " [active]" }
Write-Output "- $($plan.Name)$marker - $status"
$found = $true
}
if (-not $found) { Write-Output "No named plans found." }
}
if ($Help -or $PlanId -eq "--help" -or $PlanId -eq "-h") {
Write-Output "Usage: set-active-plan.ps1 [-List|-l|--list|PLAN_ID]"
exit 0
}
if ($List -or $PlanId -eq "--list" -or $PlanId -eq "-l") {
if ($List -and $PlanId) {
Write-Error "Error: list plans or set PLAN_ID in separate calls."
exit 1
}
Show-PlanList
exit 0
}
if ($PlanId -eq "") {
$current = ""
if ((Test-Path -LiteralPath $PlanRoot -PathType Container) -and -not (Test-WithinRoot $PlanRoot)) {
Write-Error "Error: planning directory is outside the project or cannot be verified."
exit 1
}
if (Test-WithinRoot $PlanRoot) { $current = Get-CurrentActivePlan }
if ($current) {
$planDir = Join-Path $PlanRoot $current
if ((Test-Path -LiteralPath $planDir -PathType Container) -and (Test-WithinRoot $planDir)) {
Write-Output "Active plan: $current"
Write-Output "Path: $planDir"
} else {
Write-Output "Active plan pointer: $current (directory not found or outside project - stale pointer)"
}
} else {
Write-Output "No active plan set."
}
exit 0
}
if (-not (Test-ValidSlug $PlanId)) {
Write-Error "Error: invalid plan ID. Use letters, numbers, underscores, dots, or hyphens; start with a letter, number, or underscore."
exit 1
}
$PlanDir = Join-Path $PlanRoot $PlanId
if (-not (Test-Path -LiteralPath $PlanDir -PathType Container)) {
Write-Error "Error: plan directory not found: $PlanDir"
Write-Error "Run: init-session.sh `"$PlanId`" to create it, or check .planning\ for available plans."
exit 1
}
if (-not (Test-WithinRoot $PlanRoot) -or -not (Test-WithinRoot $PlanDir)) {
Write-Error "Error: plan directory must remain within the project."
exit 1
}
$activeItem = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
if ($activeItem -and -not (Test-SafeActiveFile)) {
Write-Error "Error: the active plan pointer must be a regular file within the project."
exit 1
}
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
# Replace the directory entry instead of truncating an existing inode: a
# hardlinked pointer must not overwrite another file. The exclusive temporary
# file lives beside the pointer, keeping replacement on the same filesystem.
$tempFile = Join-Path $PlanRoot ('.active_plan.' + [guid]::NewGuid().ToString('N') + '.tmp')
$createdTemp = $false
try {
$stream = [IO.File]::Open($tempFile, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
$createdTemp = $true
try {
$bytes = $utf8NoBom.GetBytes($PlanId)
$stream.Write($bytes, 0, $bytes.Length)
$stream.Flush()
} finally { $stream.Dispose() }
if ($activeItem) {
[IO.File]::Replace($tempFile, $ActiveFile, [NullString]::Value)
} else {
[IO.File]::Move($tempFile, $ActiveFile)
}
} catch {
Write-Error "Error: could not set the active plan pointer: $($_.Exception.Message)"
exit 1
} finally {
if ($createdTemp -and (Test-Path -LiteralPath $tempFile)) {
Remove-Item -LiteralPath $tempFile -Force -ErrorAction SilentlyContinue
}
}
Write-Output "Active plan set to: $PlanId"
Write-Output "Path: $PlanDir"
Write-Output ""
Write-Output "To pin this terminal session only:"
Write-Output "`$env:PLAN_ID = '$PlanId'"
@@ -0,0 +1,335 @@
#!/bin/sh
# List saved named plans, show the shared pointer, or change that pointer.
# Usage: set-active-plan.sh [--list|-l|PLAN_ID]
# Operates on the current project; listing never binds a host or injects data.
set -eu
PLAN_ROOT="${PWD}/.planning"
ACTIVE_FILE="${PLAN_ROOT}/.active_plan"
PWF_ROOT_PIN=""
# Use the resolver canonicalization policy without running plan selection.
slug_is_valid() {
case "$1" in
'') return 1 ;;
*[!A-Za-z0-9._-]*) return 1 ;;
[A-Za-z0-9_]*) return 0 ;;
esac
return 1
}
# Pure-sh backslash-to-forward-slash normalizer; result lands in $NORM_OUT.
# Windows-native coreutils builds (e.g. C:\Program Files\coreutils on PATH
# ahead of Git's usr/bin) canonicalize MSYS-style /c/... input to C:\-style
# backslash output. The containment prefix match below is written with forward
# slashes, so without this normalization every canonical pair mismatches and
# resolution silently fails. On POSIX systems paths contain no backslash and
# this is the identity. A literal backslash in a Unix filename normalizes to
# "/" and at worst fails containment — the safe direction. No subshell, no
# fork: plain parameter expansion in a loop.
norm_slashes() {
NORM_OUT=""
_ns_rest="$1"
while :; do
case "${_ns_rest}" in
*\\*)
NORM_OUT="${NORM_OUT}${_ns_rest%%\\*}/"
_ns_rest="${_ns_rest#*\\}"
;;
*)
NORM_OUT="${NORM_OUT}${_ns_rest}"
break
;;
esac
done
}
# Return true when a candidate path names the Microsoft Store WindowsApps
# directory. Store app aliases are not stable interpreter binaries and may
# present as executable while refusing script execution. Matching is
# case-insensitive and works before or after Windows slash normalization.
is_windowsapps_path() {
norm_slashes "$1"
case "${NORM_OUT}" in
[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]|\
[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]/*|\
*/[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]|\
*/[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]/*) return 0 ;;
esac
return 1
}
# Select only an interpreter path the caller explicitly trusted.
# PWF_TRUSTED_PYTHON is preferred; PYTHON_BIN remains a compatibility alias.
# PATH discovery is intentionally forbidden because resolver hooks can run in
# repositories that control PATH. Windows-native absolute paths are converted
# with Git Bash's fixed system cygpath, never a PATH-selected shim.
trusted_python() {
for _tp_candidate in "${PWF_TRUSTED_PYTHON:-}" "${PYTHON_BIN:-}"; do
[ -n "${_tp_candidate}" ] || continue
case "${_tp_candidate}" in
\\\\*|//*) continue ;;
[A-Za-z]:[\\/]*)
is_windowsapps_path "${_tp_candidate}" && continue
_tp_cygpath="/usr/bin/cygpath.exe"
[ -f "${_tp_cygpath}" ] && [ -x "${_tp_cygpath}" ] || continue
_tp_candidate="$("${_tp_cygpath}" -u "${_tp_candidate}" 2>/dev/null)" \
|| continue
;;
/*) ;;
*) continue ;;
esac
is_windowsapps_path "${_tp_candidate}" && continue
[ -f "${_tp_candidate}" ] || continue
[ -x "${_tp_candidate}" ] || continue
printf "%s\n" "${_tp_candidate}"
return 0
done
return 1
}
# Portable path canonicalizer. realpath first (Linux, modern coreutils),
# then readlink -f (older GNU), then an explicitly trusted Python interpreter.
# Prints the canonical absolute path on success; prints nothing and returns 1
# on a full miss so containment fails closed. No Python spawn on the happy
# path: realpath/readlink cover Linux, WSL, Git-Bash, and modern macOS.
canonicalize() {
target="$1"
if command -v realpath >/dev/null 2>&1; then
out="$(realpath "${target}" 2>/dev/null)" && [ -n "${out}" ] && {
printf "%s\n" "${out}"; return 0; }
fi
if command -v readlink >/dev/null 2>&1; then
out="$(readlink -f "${target}" 2>/dev/null)" && [ -n "${out}" ] && {
printf "%s\n" "${out}"; return 0; }
fi
_canonical_python="$(trusted_python)" || _canonical_python=""
if [ -n "${_canonical_python}" ]; then
out="$("${_canonical_python}" -I -c "import os,sys;print(os.path.realpath(sys.argv[1]))" "${target}" 2>/dev/null)" \
&& [ -n "${out}" ] && { printf "%s\n" "${out}"; return 0; }
fi
return 1
}
# Containment guard (security A1.3): a resolved plan dir must canonicalize to a
# path under the project root (the CWD the script runs from). A symlink inside
# a valid slug dir pointing at /etc or outside the workspace would otherwise let
# the hooks hash and inject an arbitrary file. On any violation we return 1 so
# the caller treats the candidate as unresolved and falls back safely.
#
# The root canonicalizes via the relative token "." rather than the $PWD
# string. On some Windows/MSYS setups (8.3 short names, the /tmp mount alias)
# realpath("$PWD") and realpath(relative-candidate) resolve through different
# code paths and land on differently-spelled-but-equal targets, so the prefix
# match below fails and resolution silently goes dark. "." resolves through
# the same physical-cwd path candidates already use (same fix inject-plan.sh
# received earlier; the resolver kept the $PWD form until now). Both sides are
# backslash-normalized before comparison for Windows-native canonicalizers.
# The root is computed once per run: the newest-mtime scan calls this guard
# per plan dir, and each canonicalize costs a process spawn on Windows.
#
# With a PWF_PLAN_ROOT pin (issue #212) containment is checked against THAT
# root instead of the cwd: candidates arrive ${PWF_PLAN_ROOT}/-prefixed, so
# both sides canonicalize through the same path spelling. Unpinned keeps the
# relative "." root — byte-identical to the legacy check.
ROOT_REAL=""
ROOT_REAL_SET=0
is_within_root() {
candidate="$1"
if [ "${ROOT_REAL_SET}" = "0" ]; then
ROOT_REAL="$(canonicalize "${PWF_ROOT_PIN:-.}")" || ROOT_REAL=""
norm_slashes "${ROOT_REAL}"
ROOT_REAL="${NORM_OUT}"
ROOT_REAL_SET=1
fi
# Canonicalize the candidate through its cwd-RELATIVE form whenever it
# lives under ${PWD}. The candidate string is built from ${PWD} (an MSYS
# long-form spelling), while the root canonicalizes from "." (the process
# cwd, which a caller may have set with an 8.3 short-form string). A
# Windows-native realpath does not unify those spellings, so canonicalizing
# both sides from the same cwd base is the only spelling-stable comparison.
# The emitted result keeps the original absolute candidate — only the
# containment check uses the relative form.
# Pinned resolution skips the rewrite: candidate and root then share the
# ${PWF_PLAN_ROOT} spelling, so both canonicalize directly from it.
if [ -n "${PWF_ROOT_PIN}" ]; then
check_target="${candidate}"
else
case "${candidate}" in
"${PWD}"/*) check_target=".${candidate#"${PWD}"}" ;;
*) check_target="${candidate}" ;;
esac
fi
cand_real="$(canonicalize "${check_target}")" || cand_real=""
norm_slashes "${cand_real}"
cand_real="${NORM_OUT}"
if [ -z "${ROOT_REAL}" ] || [ -z "${cand_real}" ]; then
# Slug validation blocks textual traversal, but only successful
# canonicalization can rule out a symlink/junction escape.
return 1
fi
case "${cand_real}" in
"${ROOT_REAL}"|"${ROOT_REAL}"/*) return 0 ;;
*) return 1 ;;
esac
}
# Each phase contributes at most one status. An explicit Status line wins
# over an inline heading marker. Fenced examples and unrelated sections are
# not phases. Keep this parser aligned with the PowerShell helper.
phase_status() {
awk '
function finish() {
if (phase) {
status = primary != "" ? primary : inline_status
if (status == "complete") complete++
else if (status == "in_progress") in_progress++
else if (status == "pending") pending++
}
phase = 0; primary = ""; inline_status = ""
}
{
line = $0
sub(/\r$/, "", line)
trimmed = line
sub(/^ */, "", trimmed)
if (line ~ /^ ? ? ?```/ || line ~ /^ ? ? ?~~~/) {
marker = substr(trimmed, 1, 1)
run = 0
while (substr(trimmed, run + 1, 1) == marker) run++
if (fence == "") { fence = marker; fence_length = run }
else if (marker == fence && run >= fence_length && substr(trimmed, run + 1) ~ /^[ \t]*$/) fence = ""
next
}
if (fence != "") next
if (line ~ /^ ? ? ?###[ \t]+(Phase|Fase|المرحلة|阶段|階段)[ \t]+[0-9]+([^0-9A-Za-z_]|$)/) {
finish(); phase = 1; total++
if (match(line, /\[(complete|in_progress|pending)\]/))
inline_status = substr(line, RSTART + 1, RLENGTH - 2)
} else if (line ~ /^ ? ? ?(###|##|#)([ \t]|$)/) {
finish()
} else if (phase && primary == "" && line ~ /^ ? ? ?(-[ \t]+)?\*\*(Status:|Estado:|الحالة:|状态:|狀態:)\*\*[ \t]+(complete|in_progress|pending)([ \t]|$)/) {
sub(/^ ? ? ?(-[ \t]+)?\*\*(Status:|Estado:|الحالة:|状态:|狀態:)\*\*[ \t]+/, "", line)
sub(/[ \t].*$/, "", line)
primary = line
}
}
END {
finish()
printf "%d/%d complete, %d in_progress, %d pending", complete, total, in_progress, pending
}
' < "$1"
}
current_active() {
if [ -f "${ACTIVE_FILE}" ] && is_within_root "${ACTIVE_FILE}"; then
_current="$(tr '\r' '\n' < "${ACTIVE_FILE}")"
# Windows editors and older PowerShell defaults can leave a UTF-8 BOM.
# Treat it as an encoding marker, not part of the shared plan slug.
_utf8_bom="$(printf '\357\273\277')"
case "${_current}" in
"${_utf8_bom}"*) _current="${_current#"${_utf8_bom}"}" ;;
esac
slug_is_valid "${_current}" && printf '%s\n' "${_current}"
fi
return 0
}
list_plans() {
if [ ! -d "${PLAN_ROOT}" ]; then
printf '%s\n' 'No planning directory found.'
return 0
fi
if ! is_within_root "${PLAN_ROOT}"; then
printf '%s\n' 'Error: planning directory is outside the project or cannot be verified.' >&2
return 1
fi
_active="$(current_active)"
_found=0
printf '%s\n' 'Available plans:'
for _dir in "${PLAN_ROOT}"/*; do
[ -d "${_dir}" ] || continue
_id="${_dir##*/}"
slug_is_valid "${_id}" || continue
is_within_root "${_dir}" || continue
_plan_file="${_dir}/task_plan.md"
[ -f "${_plan_file}" ] && [ -r "${_plan_file}" ] || continue
is_within_root "${_plan_file}" || continue
_status="$(phase_status "${_plan_file}")" || continue
_marker=''
if [ "${_id}" = "${_active}" ]; then _marker=' [active]'; fi
printf '%s\n' "- ${_id}${_marker} - ${_status}"
_found=1
done
if [ "${_found}" -eq 0 ]; then
printf '%s\n' 'No named plans found.'
else
printf '%s\n' '[active] marks the shared default pointer. Set PLAN_ID to pin a session.'
fi
}
if [ "$#" -gt 1 ]; then
printf '%s\n' 'Error: list plans or set PLAN_ID in separate calls.' >&2
exit 1
fi
case "${1:-}" in
--list|-l) list_plans; exit $? ;;
--help|-h)
printf '%s\n' 'Usage: set-active-plan.sh [--list|PLAN_ID]' \
'Lists saved named plans in the current project without selecting a plan.'
exit 0 ;;
esac
if [ "${1:-}" = '' ]; then
if [ -d "${PLAN_ROOT}" ] && ! is_within_root "${PLAN_ROOT}"; then
printf '%s\n' 'Error: planning directory is outside the project or cannot be verified.' >&2
exit 1
fi
plan_id="$(current_active)"
if [ -n "${plan_id}" ] && [ -d "${PLAN_ROOT}/${plan_id}" ] && is_within_root "${PLAN_ROOT}/${plan_id}"; then
printf '%s\n' "Active plan: ${plan_id}" "Path: ${PLAN_ROOT}/${plan_id}"
elif [ -n "${plan_id}" ]; then
printf '%s\n' "Active plan pointer: ${plan_id} (directory not found or outside project - stale pointer)"
else
printf '%s\n' 'No active plan set.'
fi
exit 0
fi
PLAN_ID="$1"
if ! slug_is_valid "${PLAN_ID}"; then
printf '%s\n' 'Error: invalid plan ID. Use a named directory under .planning.' >&2
exit 1
fi
PLAN_DIR="${PLAN_ROOT}/${PLAN_ID}"
if [ ! -d "${PLAN_DIR}" ]; then
printf '%s\n' "Error: plan directory not found: ${PLAN_DIR}" \
"Run: init-session.sh \"${PLAN_ID}\" to create it, or use --list to see available plans." >&2
exit 1
fi
if ! is_within_root "${PLAN_ROOT}" || ! is_within_root "${PLAN_DIR}"; then
printf '%s\n' 'Error: plan directory is outside the project or cannot be verified.' >&2
exit 1
fi
# A pre-existing pointer must itself be a contained regular file before writing.
if { [ -e "${ACTIVE_FILE}" ] || [ -L "${ACTIVE_FILE}" ]; } &&
{ [ -L "${ACTIVE_FILE}" ] || [ ! -f "${ACTIVE_FILE}" ] || ! is_within_root "${ACTIVE_FILE}"; }; then
printf '%s\n' 'Error: active plan pointer is not a safe file inside the project.' >&2
exit 1
fi
# Replace the pointer atomically instead of truncating a possible hardlink.
# mktemp creates a private, exclusive file beside the destination.
temp_file="$(mktemp "${PLAN_ROOT}/.active_plan.XXXXXX")" || {
printf '%s\n' 'Error: could not create the active plan pointer.' >&2
exit 1
}
trap 'rm -f "${temp_file}"' EXIT
trap 'exit 1' HUP INT TERM
printf '%s\n' "${PLAN_ID}" > "${temp_file}"
if ! mv -f "${temp_file}" "${ACTIVE_FILE}"; then
printf '%s\n' 'Error: could not replace the active plan pointer.' >&2
exit 1
fi
trap - EXIT HUP INT TERM
printf '%s\n' "Active plan set to: ${PLAN_ID}" "Path: ${PLAN_DIR}" '' \
'To pin this terminal session only:' " export PLAN_ID=${PLAN_ID}"
@@ -36,7 +36,7 @@ hooks:
- type: command
command: "SH=\"\"; for c in \"${PWF_SCRIPT_DIR}/skill-hook.sh\" \"${CLAUDE_SKILL_DIR}/scripts/skill-hook.sh\" \"$HOME/.config/opencode/skills/planning-with-files/scripts/skill-hook.sh\" \"$HOME/.opencode/skills/planning-with-files/scripts/skill-hook.sh\" \"$HOME/.claude/skills/planning-with-files/scripts/skill-hook.sh\" \"$HOME/.claude/plugins/marketplaces/planning-with-files/scripts/skill-hook.sh\"; do [ -f \"$c\" ] && { SH=\"$c\"; break; }; done; [ -n \"$SH\" ] && sh \"$SH\" --event=precompact; exit 0"
metadata:
version: "3.17.2"
version: "3.18.2"
---
@@ -220,6 +220,12 @@ Helper scripts for automation:
- `scripts/check-complete.sh` — Verify all phases complete
- `scripts/session-catchup.py`: Explicit same-project session-record aggregation or bounded replay (`--metadata` / `--replay`); bare invocation does not access host history
### List saved plans
To find a task before resuming it, run `sh "<skill-dir>/scripts/set-active-plan.sh" --list` or, in Windows PowerShell, `& "<skill-dir>/scripts/set-active-plan.ps1" -List`. Replace `<skill-dir>` with this installed skill directory and keep your current directory at the project root.
This read-only command lists named plans and phase progress under the current directory's `.planning/`. `[active]` marks the shared default pointer; it does not bind a session. Concurrent tasks still require each host's `PLAN_ID` or separate worktrees.
## Advanced Topics
- **Manus Principles:** See [reference.md](reference.md)
@@ -0,0 +1,301 @@
# planning-with-files: set, display, or list the active plan pointer (PowerShell).
#
# Usage:
# .\set-active-plan.ps1 <plan_id> - pin .planning\.active_plan to plan_id
# .\set-active-plan.ps1 - print the current active plan (if any)
# .\set-active-plan.ps1 -List - list available named plans and phase counts
# .\set-active-plan.ps1 --list - equivalent to -List and -l
param(
[Parameter(Position = 0)]
[string]$PlanId = "",
[Alias('l', '-list')]
[switch]$List,
[Alias('h', '-help')]
[switch]$Help
)
$ProjectRoot = (Get-Location).Path
# Windows PowerShell 5.1 can silently change directories when -File inherits
# a cwd containing wildcard characters. Recover that physical cwd only for a
# direct invocation of this script; interactive Set-Location remains primary.
if ($PSVersionTable.PSVersion.Major -eq 5 -and
[System.Management.Automation.WildcardPattern]::ContainsWildcardCharacters([Environment]::CurrentDirectory)) {
$processArgs = [Environment]::GetCommandLineArgs()
for ($index = 0; $index -lt ($processArgs.Length - 1); $index++) {
if ($processArgs[$index] -ieq '-File') {
$entryScript = $processArgs[$index + 1]
if (-not [IO.Path]::IsPathRooted($entryScript)) {
$entryScript = Join-Path ([Environment]::CurrentDirectory) $entryScript
}
if ([IO.Path]::GetFullPath($entryScript) -eq [IO.Path]::GetFullPath($PSCommandPath)) {
$ProjectRoot = [Environment]::CurrentDirectory
}
break
}
}
}
$PlanRoot = Join-Path $ProjectRoot ".planning"
$ActiveFile = Join-Path $PlanRoot ".active_plan"
$script:IsWindowsHost = [Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT
# Resolve-Path can retain a junction's spelling. Like resolve-plan-dir.ps1,
# open the actual filesystem object before checking the project boundary.
# The Windows handle supports both directories and task_plan.md files.
function Get-FinalPath {
param([string]$Path)
if (-not ("PwfPlanListingNative" -as [type])) {
Add-Type -ErrorAction Stop -TypeDefinition @'
using System;
using System.ComponentModel;
using System.Runtime.InteropServices;
using System.Text;
using Microsoft.Win32.SafeHandles;
public static class PwfPlanListingNative {
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern SafeFileHandle CreateFileW(
string name, uint access, uint share, IntPtr security,
uint creation, uint flags, IntPtr template);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern uint GetFinalPathNameByHandleW(
SafeFileHandle handle, StringBuilder path, uint length, uint flags);
[DllImport("libc", SetLastError = true)]
private static extern IntPtr realpath(string path, IntPtr resolved);
[DllImport("libc")]
private static extern void free(IntPtr pointer);
public static string FinalPath(string path) {
if (Environment.OSVersion.Platform != PlatformID.Win32NT) {
IntPtr resolved = realpath(path, IntPtr.Zero);
if (resolved == IntPtr.Zero) throw new Win32Exception(Marshal.GetLastWin32Error());
try { return Marshal.PtrToStringAnsi(resolved); }
finally { free(resolved); }
}
// OPEN_EXISTING, FILE_SHARE_READ | WRITE | DELETE, BACKUP_SEMANTICS.
using (SafeFileHandle handle = CreateFileW(
path, 0, 7, IntPtr.Zero, 3, 0x02000000, IntPtr.Zero)) {
if (handle.IsInvalid) throw new Win32Exception(Marshal.GetLastWin32Error());
StringBuilder buffer = new StringBuilder(32768);
uint length = GetFinalPathNameByHandleW(handle, buffer, (uint)buffer.Capacity, 0);
if (length == 0 || length >= buffer.Capacity)
throw new Win32Exception(Marshal.GetLastWin32Error());
string result = buffer.ToString();
if (result.StartsWith(@"\\?\UNC\", StringComparison.OrdinalIgnoreCase))
return @"\\" + result.Substring(8);
if (result.StartsWith(@"\\?\", StringComparison.OrdinalIgnoreCase))
return result.Substring(4);
return result;
}
}
}
'@
}
return [PwfPlanListingNative]::FinalPath($Path)
}
function Test-WithinRoot {
param([string]$Path)
try {
$rootReal = (Get-FinalPath $ProjectRoot).TrimEnd('\', '/')
$pathReal = (Get-FinalPath $Path).TrimEnd('\', '/')
$comparison = [StringComparison]::Ordinal
if ($script:IsWindowsHost) { $comparison = [StringComparison]::OrdinalIgnoreCase }
return $pathReal.Equals($rootReal, $comparison) -or
$pathReal.StartsWith($rootReal + [IO.Path]::DirectorySeparatorChar, $comparison)
} catch {
return $false
}
}
function Test-ValidSlug {
param([string]$Name)
return $Name -cmatch '^[A-Za-z0-9_][A-Za-z0-9._-]*\z'
}
function Test-SafeActiveFile {
param([switch]$AllowLink)
$item = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
if (-not $item) { return $false }
# Reading may follow a verified in-project link; writing must not.
return -not $item.PSIsContainer -and
($AllowLink -or (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0)) -and
(Test-WithinRoot $ActiveFile)
}
function Get-CurrentActivePlan {
if (-not (Test-SafeActiveFile -AllowLink)) { return "" }
try {
$current = ([string](Get-Content -LiteralPath $ActiveFile -Raw -Encoding UTF8 -ErrorAction Stop)).Trim([char[]]"`r`n")
if (Test-ValidSlug $current) { return $current }
} catch { }
return ""
}
function Add-PhaseStatus {
param([hashtable]$Counts, $Phase)
if ($null -eq $Phase) { return }
$status = $Phase.Inline
if ($Phase.Primary) { $status = $Phase.Primary }
if ($status) { $Counts[$status]++ }
}
function Get-PhaseStatus {
param([string]$PlanFile)
$counts = @{ total = 0; complete = 0; in_progress = 0; pending = 0 }
$phase = $null
$fence = ""
$fenceLength = 0
# The shipped translations keep status identifiers in English. Unicode
# escapes preserve Windows PowerShell 5.1 compatibility without a BOM.
$phaseLabel = '(?:Phase|Fase|\u0627\u0644\u0645\u0631\u062d\u0644\u0629|\u9636\u6bb5|\u968e\u6bb5)'
$statusLabel = '(?:Status:|Estado:|\u0627\u0644\u062d\u0627\u0644\u0629:|\u72b6\u6001\uff1a|\u72c0\u614b\uff1a)'
# Read once; an empty file is a valid plan with zero phases.
$content = [string](Get-Content -LiteralPath $PlanFile -Raw -Encoding UTF8 -ErrorAction Stop)
foreach ($line in ($content -split '\r?\n')) {
if ($fence) {
$closing = '^ {0,3}' + [regex]::Escape($fence) + '{' + $fenceLength + ',}[ \t]*$'
if ($line -match $closing) { $fence = "" }
continue
}
if ($line -match '^ {0,3}(`{3,}|~{3,})') {
$fence = $Matches[1].Substring(0, 1)
$fenceLength = $Matches[1].Length
continue
}
if ($line -cmatch ('^ {0,3}###[ \t]+' + $phaseLabel + '[ \t]+[0-9]+(?:[^0-9A-Za-z_]|$)')) {
Add-PhaseStatus $counts $phase
$phase = @{ Primary = ""; Inline = "" }
$counts.total++
if ($line -cmatch '\[(complete|in_progress|pending)\]') {
$phase.Inline = $Matches[1]
}
continue
}
if ($line -match '^ {0,3}#{1,3}(?:[ \t]+|$)') {
Add-PhaseStatus $counts $phase
$phase = $null
continue
}
if ($null -ne $phase -and -not $phase.Primary -and
$line -cmatch ('^ {0,3}(?:-[ \t]+)?\*\*' + $statusLabel + '\*\*[ \t]+(complete|in_progress|pending)(?:[ \t]|$)')) {
$phase.Primary = $Matches[1]
}
}
Add-PhaseStatus $counts $phase
return "$($counts.complete)/$($counts.total) complete, $($counts.in_progress) in_progress, $($counts.pending) pending"
}
function Show-PlanList {
if (-not (Test-Path -LiteralPath $PlanRoot -PathType Container)) {
Write-Output "No planning directory found."
return
}
if (-not (Test-WithinRoot $PlanRoot)) {
Write-Error "Error: planning directory is outside the project or cannot be verified."
exit 1
}
$active = Get-CurrentActivePlan
Write-Output "Available plans:"
Write-Output "[active] marks the shared .active_plan pointer; listing does not bind this session."
$found = $false
foreach ($plan in (Get-ChildItem -LiteralPath $PlanRoot -Directory -ErrorAction SilentlyContinue | Sort-Object Name)) {
if (-not (Test-ValidSlug $plan.Name) -or -not (Test-WithinRoot $plan.FullName)) { continue }
$planFile = Join-Path $plan.FullName "task_plan.md"
if (-not (Test-Path -LiteralPath $planFile -PathType Leaf) -or -not (Test-WithinRoot $planFile)) { continue }
try { $status = Get-PhaseStatus $planFile } catch { continue }
$marker = ""
if ($plan.Name -ceq $active) { $marker = " [active]" }
Write-Output "- $($plan.Name)$marker - $status"
$found = $true
}
if (-not $found) { Write-Output "No named plans found." }
}
if ($Help -or $PlanId -eq "--help" -or $PlanId -eq "-h") {
Write-Output "Usage: set-active-plan.ps1 [-List|-l|--list|PLAN_ID]"
exit 0
}
if ($List -or $PlanId -eq "--list" -or $PlanId -eq "-l") {
if ($List -and $PlanId) {
Write-Error "Error: list plans or set PLAN_ID in separate calls."
exit 1
}
Show-PlanList
exit 0
}
if ($PlanId -eq "") {
$current = ""
if ((Test-Path -LiteralPath $PlanRoot -PathType Container) -and -not (Test-WithinRoot $PlanRoot)) {
Write-Error "Error: planning directory is outside the project or cannot be verified."
exit 1
}
if (Test-WithinRoot $PlanRoot) { $current = Get-CurrentActivePlan }
if ($current) {
$planDir = Join-Path $PlanRoot $current
if ((Test-Path -LiteralPath $planDir -PathType Container) -and (Test-WithinRoot $planDir)) {
Write-Output "Active plan: $current"
Write-Output "Path: $planDir"
} else {
Write-Output "Active plan pointer: $current (directory not found or outside project - stale pointer)"
}
} else {
Write-Output "No active plan set."
}
exit 0
}
if (-not (Test-ValidSlug $PlanId)) {
Write-Error "Error: invalid plan ID. Use letters, numbers, underscores, dots, or hyphens; start with a letter, number, or underscore."
exit 1
}
$PlanDir = Join-Path $PlanRoot $PlanId
if (-not (Test-Path -LiteralPath $PlanDir -PathType Container)) {
Write-Error "Error: plan directory not found: $PlanDir"
Write-Error "Run: init-session.sh `"$PlanId`" to create it, or check .planning\ for available plans."
exit 1
}
if (-not (Test-WithinRoot $PlanRoot) -or -not (Test-WithinRoot $PlanDir)) {
Write-Error "Error: plan directory must remain within the project."
exit 1
}
$activeItem = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
if ($activeItem -and -not (Test-SafeActiveFile)) {
Write-Error "Error: the active plan pointer must be a regular file within the project."
exit 1
}
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
# Replace the directory entry instead of truncating an existing inode: a
# hardlinked pointer must not overwrite another file. The exclusive temporary
# file lives beside the pointer, keeping replacement on the same filesystem.
$tempFile = Join-Path $PlanRoot ('.active_plan.' + [guid]::NewGuid().ToString('N') + '.tmp')
$createdTemp = $false
try {
$stream = [IO.File]::Open($tempFile, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
$createdTemp = $true
try {
$bytes = $utf8NoBom.GetBytes($PlanId)
$stream.Write($bytes, 0, $bytes.Length)
$stream.Flush()
} finally { $stream.Dispose() }
if ($activeItem) {
[IO.File]::Replace($tempFile, $ActiveFile, [NullString]::Value)
} else {
[IO.File]::Move($tempFile, $ActiveFile)
}
} catch {
Write-Error "Error: could not set the active plan pointer: $($_.Exception.Message)"
exit 1
} finally {
if ($createdTemp -and (Test-Path -LiteralPath $tempFile)) {
Remove-Item -LiteralPath $tempFile -Force -ErrorAction SilentlyContinue
}
}
Write-Output "Active plan set to: $PlanId"
Write-Output "Path: $PlanDir"
Write-Output ""
Write-Output "To pin this terminal session only:"
Write-Output "`$env:PLAN_ID = '$PlanId'"
@@ -0,0 +1,335 @@
#!/bin/sh
# List saved named plans, show the shared pointer, or change that pointer.
# Usage: set-active-plan.sh [--list|-l|PLAN_ID]
# Operates on the current project; listing never binds a host or injects data.
set -eu
PLAN_ROOT="${PWD}/.planning"
ACTIVE_FILE="${PLAN_ROOT}/.active_plan"
PWF_ROOT_PIN=""
# Use the resolver canonicalization policy without running plan selection.
slug_is_valid() {
case "$1" in
'') return 1 ;;
*[!A-Za-z0-9._-]*) return 1 ;;
[A-Za-z0-9_]*) return 0 ;;
esac
return 1
}
# Pure-sh backslash-to-forward-slash normalizer; result lands in $NORM_OUT.
# Windows-native coreutils builds (e.g. C:\Program Files\coreutils on PATH
# ahead of Git's usr/bin) canonicalize MSYS-style /c/... input to C:\-style
# backslash output. The containment prefix match below is written with forward
# slashes, so without this normalization every canonical pair mismatches and
# resolution silently fails. On POSIX systems paths contain no backslash and
# this is the identity. A literal backslash in a Unix filename normalizes to
# "/" and at worst fails containment — the safe direction. No subshell, no
# fork: plain parameter expansion in a loop.
norm_slashes() {
NORM_OUT=""
_ns_rest="$1"
while :; do
case "${_ns_rest}" in
*\\*)
NORM_OUT="${NORM_OUT}${_ns_rest%%\\*}/"
_ns_rest="${_ns_rest#*\\}"
;;
*)
NORM_OUT="${NORM_OUT}${_ns_rest}"
break
;;
esac
done
}
# Return true when a candidate path names the Microsoft Store WindowsApps
# directory. Store app aliases are not stable interpreter binaries and may
# present as executable while refusing script execution. Matching is
# case-insensitive and works before or after Windows slash normalization.
is_windowsapps_path() {
norm_slashes "$1"
case "${NORM_OUT}" in
[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]|\
[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]/*|\
*/[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]|\
*/[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]/*) return 0 ;;
esac
return 1
}
# Select only an interpreter path the caller explicitly trusted.
# PWF_TRUSTED_PYTHON is preferred; PYTHON_BIN remains a compatibility alias.
# PATH discovery is intentionally forbidden because resolver hooks can run in
# repositories that control PATH. Windows-native absolute paths are converted
# with Git Bash's fixed system cygpath, never a PATH-selected shim.
trusted_python() {
for _tp_candidate in "${PWF_TRUSTED_PYTHON:-}" "${PYTHON_BIN:-}"; do
[ -n "${_tp_candidate}" ] || continue
case "${_tp_candidate}" in
\\\\*|//*) continue ;;
[A-Za-z]:[\\/]*)
is_windowsapps_path "${_tp_candidate}" && continue
_tp_cygpath="/usr/bin/cygpath.exe"
[ -f "${_tp_cygpath}" ] && [ -x "${_tp_cygpath}" ] || continue
_tp_candidate="$("${_tp_cygpath}" -u "${_tp_candidate}" 2>/dev/null)" \
|| continue
;;
/*) ;;
*) continue ;;
esac
is_windowsapps_path "${_tp_candidate}" && continue
[ -f "${_tp_candidate}" ] || continue
[ -x "${_tp_candidate}" ] || continue
printf "%s\n" "${_tp_candidate}"
return 0
done
return 1
}
# Portable path canonicalizer. realpath first (Linux, modern coreutils),
# then readlink -f (older GNU), then an explicitly trusted Python interpreter.
# Prints the canonical absolute path on success; prints nothing and returns 1
# on a full miss so containment fails closed. No Python spawn on the happy
# path: realpath/readlink cover Linux, WSL, Git-Bash, and modern macOS.
canonicalize() {
target="$1"
if command -v realpath >/dev/null 2>&1; then
out="$(realpath "${target}" 2>/dev/null)" && [ -n "${out}" ] && {
printf "%s\n" "${out}"; return 0; }
fi
if command -v readlink >/dev/null 2>&1; then
out="$(readlink -f "${target}" 2>/dev/null)" && [ -n "${out}" ] && {
printf "%s\n" "${out}"; return 0; }
fi
_canonical_python="$(trusted_python)" || _canonical_python=""
if [ -n "${_canonical_python}" ]; then
out="$("${_canonical_python}" -I -c "import os,sys;print(os.path.realpath(sys.argv[1]))" "${target}" 2>/dev/null)" \
&& [ -n "${out}" ] && { printf "%s\n" "${out}"; return 0; }
fi
return 1
}
# Containment guard (security A1.3): a resolved plan dir must canonicalize to a
# path under the project root (the CWD the script runs from). A symlink inside
# a valid slug dir pointing at /etc or outside the workspace would otherwise let
# the hooks hash and inject an arbitrary file. On any violation we return 1 so
# the caller treats the candidate as unresolved and falls back safely.
#
# The root canonicalizes via the relative token "." rather than the $PWD
# string. On some Windows/MSYS setups (8.3 short names, the /tmp mount alias)
# realpath("$PWD") and realpath(relative-candidate) resolve through different
# code paths and land on differently-spelled-but-equal targets, so the prefix
# match below fails and resolution silently goes dark. "." resolves through
# the same physical-cwd path candidates already use (same fix inject-plan.sh
# received earlier; the resolver kept the $PWD form until now). Both sides are
# backslash-normalized before comparison for Windows-native canonicalizers.
# The root is computed once per run: the newest-mtime scan calls this guard
# per plan dir, and each canonicalize costs a process spawn on Windows.
#
# With a PWF_PLAN_ROOT pin (issue #212) containment is checked against THAT
# root instead of the cwd: candidates arrive ${PWF_PLAN_ROOT}/-prefixed, so
# both sides canonicalize through the same path spelling. Unpinned keeps the
# relative "." root — byte-identical to the legacy check.
ROOT_REAL=""
ROOT_REAL_SET=0
is_within_root() {
candidate="$1"
if [ "${ROOT_REAL_SET}" = "0" ]; then
ROOT_REAL="$(canonicalize "${PWF_ROOT_PIN:-.}")" || ROOT_REAL=""
norm_slashes "${ROOT_REAL}"
ROOT_REAL="${NORM_OUT}"
ROOT_REAL_SET=1
fi
# Canonicalize the candidate through its cwd-RELATIVE form whenever it
# lives under ${PWD}. The candidate string is built from ${PWD} (an MSYS
# long-form spelling), while the root canonicalizes from "." (the process
# cwd, which a caller may have set with an 8.3 short-form string). A
# Windows-native realpath does not unify those spellings, so canonicalizing
# both sides from the same cwd base is the only spelling-stable comparison.
# The emitted result keeps the original absolute candidate — only the
# containment check uses the relative form.
# Pinned resolution skips the rewrite: candidate and root then share the
# ${PWF_PLAN_ROOT} spelling, so both canonicalize directly from it.
if [ -n "${PWF_ROOT_PIN}" ]; then
check_target="${candidate}"
else
case "${candidate}" in
"${PWD}"/*) check_target=".${candidate#"${PWD}"}" ;;
*) check_target="${candidate}" ;;
esac
fi
cand_real="$(canonicalize "${check_target}")" || cand_real=""
norm_slashes "${cand_real}"
cand_real="${NORM_OUT}"
if [ -z "${ROOT_REAL}" ] || [ -z "${cand_real}" ]; then
# Slug validation blocks textual traversal, but only successful
# canonicalization can rule out a symlink/junction escape.
return 1
fi
case "${cand_real}" in
"${ROOT_REAL}"|"${ROOT_REAL}"/*) return 0 ;;
*) return 1 ;;
esac
}
# Each phase contributes at most one status. An explicit Status line wins
# over an inline heading marker. Fenced examples and unrelated sections are
# not phases. Keep this parser aligned with the PowerShell helper.
phase_status() {
awk '
function finish() {
if (phase) {
status = primary != "" ? primary : inline_status
if (status == "complete") complete++
else if (status == "in_progress") in_progress++
else if (status == "pending") pending++
}
phase = 0; primary = ""; inline_status = ""
}
{
line = $0
sub(/\r$/, "", line)
trimmed = line
sub(/^ */, "", trimmed)
if (line ~ /^ ? ? ?```/ || line ~ /^ ? ? ?~~~/) {
marker = substr(trimmed, 1, 1)
run = 0
while (substr(trimmed, run + 1, 1) == marker) run++
if (fence == "") { fence = marker; fence_length = run }
else if (marker == fence && run >= fence_length && substr(trimmed, run + 1) ~ /^[ \t]*$/) fence = ""
next
}
if (fence != "") next
if (line ~ /^ ? ? ?###[ \t]+(Phase|Fase|المرحلة|阶段|階段)[ \t]+[0-9]+([^0-9A-Za-z_]|$)/) {
finish(); phase = 1; total++
if (match(line, /\[(complete|in_progress|pending)\]/))
inline_status = substr(line, RSTART + 1, RLENGTH - 2)
} else if (line ~ /^ ? ? ?(###|##|#)([ \t]|$)/) {
finish()
} else if (phase && primary == "" && line ~ /^ ? ? ?(-[ \t]+)?\*\*(Status:|Estado:|الحالة:|状态:|狀態:)\*\*[ \t]+(complete|in_progress|pending)([ \t]|$)/) {
sub(/^ ? ? ?(-[ \t]+)?\*\*(Status:|Estado:|الحالة:|状态:|狀態:)\*\*[ \t]+/, "", line)
sub(/[ \t].*$/, "", line)
primary = line
}
}
END {
finish()
printf "%d/%d complete, %d in_progress, %d pending", complete, total, in_progress, pending
}
' < "$1"
}
current_active() {
if [ -f "${ACTIVE_FILE}" ] && is_within_root "${ACTIVE_FILE}"; then
_current="$(tr '\r' '\n' < "${ACTIVE_FILE}")"
# Windows editors and older PowerShell defaults can leave a UTF-8 BOM.
# Treat it as an encoding marker, not part of the shared plan slug.
_utf8_bom="$(printf '\357\273\277')"
case "${_current}" in
"${_utf8_bom}"*) _current="${_current#"${_utf8_bom}"}" ;;
esac
slug_is_valid "${_current}" && printf '%s\n' "${_current}"
fi
return 0
}
list_plans() {
if [ ! -d "${PLAN_ROOT}" ]; then
printf '%s\n' 'No planning directory found.'
return 0
fi
if ! is_within_root "${PLAN_ROOT}"; then
printf '%s\n' 'Error: planning directory is outside the project or cannot be verified.' >&2
return 1
fi
_active="$(current_active)"
_found=0
printf '%s\n' 'Available plans:'
for _dir in "${PLAN_ROOT}"/*; do
[ -d "${_dir}" ] || continue
_id="${_dir##*/}"
slug_is_valid "${_id}" || continue
is_within_root "${_dir}" || continue
_plan_file="${_dir}/task_plan.md"
[ -f "${_plan_file}" ] && [ -r "${_plan_file}" ] || continue
is_within_root "${_plan_file}" || continue
_status="$(phase_status "${_plan_file}")" || continue
_marker=''
if [ "${_id}" = "${_active}" ]; then _marker=' [active]'; fi
printf '%s\n' "- ${_id}${_marker} - ${_status}"
_found=1
done
if [ "${_found}" -eq 0 ]; then
printf '%s\n' 'No named plans found.'
else
printf '%s\n' '[active] marks the shared default pointer. Set PLAN_ID to pin a session.'
fi
}
if [ "$#" -gt 1 ]; then
printf '%s\n' 'Error: list plans or set PLAN_ID in separate calls.' >&2
exit 1
fi
case "${1:-}" in
--list|-l) list_plans; exit $? ;;
--help|-h)
printf '%s\n' 'Usage: set-active-plan.sh [--list|PLAN_ID]' \
'Lists saved named plans in the current project without selecting a plan.'
exit 0 ;;
esac
if [ "${1:-}" = '' ]; then
if [ -d "${PLAN_ROOT}" ] && ! is_within_root "${PLAN_ROOT}"; then
printf '%s\n' 'Error: planning directory is outside the project or cannot be verified.' >&2
exit 1
fi
plan_id="$(current_active)"
if [ -n "${plan_id}" ] && [ -d "${PLAN_ROOT}/${plan_id}" ] && is_within_root "${PLAN_ROOT}/${plan_id}"; then
printf '%s\n' "Active plan: ${plan_id}" "Path: ${PLAN_ROOT}/${plan_id}"
elif [ -n "${plan_id}" ]; then
printf '%s\n' "Active plan pointer: ${plan_id} (directory not found or outside project - stale pointer)"
else
printf '%s\n' 'No active plan set.'
fi
exit 0
fi
PLAN_ID="$1"
if ! slug_is_valid "${PLAN_ID}"; then
printf '%s\n' 'Error: invalid plan ID. Use a named directory under .planning.' >&2
exit 1
fi
PLAN_DIR="${PLAN_ROOT}/${PLAN_ID}"
if [ ! -d "${PLAN_DIR}" ]; then
printf '%s\n' "Error: plan directory not found: ${PLAN_DIR}" \
"Run: init-session.sh \"${PLAN_ID}\" to create it, or use --list to see available plans." >&2
exit 1
fi
if ! is_within_root "${PLAN_ROOT}" || ! is_within_root "${PLAN_DIR}"; then
printf '%s\n' 'Error: plan directory is outside the project or cannot be verified.' >&2
exit 1
fi
# A pre-existing pointer must itself be a contained regular file before writing.
if { [ -e "${ACTIVE_FILE}" ] || [ -L "${ACTIVE_FILE}" ]; } &&
{ [ -L "${ACTIVE_FILE}" ] || [ ! -f "${ACTIVE_FILE}" ] || ! is_within_root "${ACTIVE_FILE}"; }; then
printf '%s\n' 'Error: active plan pointer is not a safe file inside the project.' >&2
exit 1
fi
# Replace the pointer atomically instead of truncating a possible hardlink.
# mktemp creates a private, exclusive file beside the destination.
temp_file="$(mktemp "${PLAN_ROOT}/.active_plan.XXXXXX")" || {
printf '%s\n' 'Error: could not create the active plan pointer.' >&2
exit 1
}
trap 'rm -f "${temp_file}"' EXIT
trap 'exit 1' HUP INT TERM
printf '%s\n' "${PLAN_ID}" > "${temp_file}"
if ! mv -f "${temp_file}" "${ACTIVE_FILE}"; then
printf '%s\n' 'Error: could not replace the active plan pointer.' >&2
exit 1
fi
trap - EXIT HUP INT TERM
printf '%s\n' "Active plan set to: ${PLAN_ID}" "Path: ${PLAN_DIR}" '' \
'To pin this terminal session only:' " export PLAN_ID=${PLAN_ID}"
+6
View File
@@ -228,6 +228,12 @@ Helper scripts for automation:
- `scripts/attest-plan.sh` (and `.ps1`) — Lock the current `task_plan.md` content with a SHA-256 attestation (v2.37.0). Hooks then refuse to inject plan content if the file diverges from the attested hash. Use `--show` to print the stored hash, `--clear` to remove the attestation. See `/plan-attest` command.
- `scripts/plan-doctor.sh` — One-pass self-check for the mechanisms that fail silently (v3.6.0): plan resolution, hook injection, canonicalizer path shape, attestation state, install surfaces, per-fire hook latency. Run it whenever hooks seem quiet or after installing on a new machine. See `/plan-doctor` command.
### List saved plans
To find a task before resuming it, run `sh "<skill-dir>/scripts/set-active-plan.sh" --list` or, in Windows PowerShell, `& "<skill-dir>/scripts/set-active-plan.ps1" -List`. Replace `<skill-dir>` with this installed skill directory and keep your current directory at the project root.
This read-only command lists named plans and phase progress under the current directory's `.planning/`. `[active]` marks the shared default pointer; it does not bind a session. Concurrent tasks still require each host's `PLAN_ID` or separate worktrees.
### Parallel task workflow
For independent tasks in the same repository, create a named plan for each and pin each agent host to its own plan:
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "planning-with-files",
"version": "3.17.2",
"version": "3.18.2",
"description": "Persistent project planning with selected context injection. Automatic recovery uses project files only; explicit catchup modes read same-project local session records for aggregate counts or bounded replay. The host-aware gate never runs Markdown-declared commands. No network upload path. Ships the skill plus a Pi Coding Agent extension.",
"keywords": [
"pi-package",
+266 -16
View File
@@ -1,27 +1,245 @@
# planning-with-files: set or display the active plan pointer (PowerShell).
# planning-with-files: set, display, or list the active plan pointer (PowerShell).
#
# Usage:
# .\set-active-plan.ps1 <plan_id> - pin .planning\.active_plan to plan_id
# .\set-active-plan.ps1 - print the current active plan (if any)
# .\set-active-plan.ps1 -List - list available named plans and phase counts
# .\set-active-plan.ps1 --list - equivalent to -List and -l
param(
[string]$PlanId = ""
[Parameter(Position = 0)]
[string]$PlanId = "",
[Alias('l', '-list')]
[switch]$List,
[Alias('h', '-help')]
[switch]$Help
)
$PlanRoot = Join-Path (Get-Location) ".planning"
$ProjectRoot = (Get-Location).Path
# Windows PowerShell 5.1 can silently change directories when -File inherits
# a cwd containing wildcard characters. Recover that physical cwd only for a
# direct invocation of this script; interactive Set-Location remains primary.
if ($PSVersionTable.PSVersion.Major -eq 5 -and
[System.Management.Automation.WildcardPattern]::ContainsWildcardCharacters([Environment]::CurrentDirectory)) {
$processArgs = [Environment]::GetCommandLineArgs()
for ($index = 0; $index -lt ($processArgs.Length - 1); $index++) {
if ($processArgs[$index] -ieq '-File') {
$entryScript = $processArgs[$index + 1]
if (-not [IO.Path]::IsPathRooted($entryScript)) {
$entryScript = Join-Path ([Environment]::CurrentDirectory) $entryScript
}
if ([IO.Path]::GetFullPath($entryScript) -eq [IO.Path]::GetFullPath($PSCommandPath)) {
$ProjectRoot = [Environment]::CurrentDirectory
}
break
}
}
}
$PlanRoot = Join-Path $ProjectRoot ".planning"
$ActiveFile = Join-Path $PlanRoot ".active_plan"
$script:IsWindowsHost = [Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT
# Resolve-Path can retain a junction's spelling. Like resolve-plan-dir.ps1,
# open the actual filesystem object before checking the project boundary.
# The Windows handle supports both directories and task_plan.md files.
function Get-FinalPath {
param([string]$Path)
if (-not ("PwfPlanListingNative" -as [type])) {
Add-Type -ErrorAction Stop -TypeDefinition @'
using System;
using System.ComponentModel;
using System.Runtime.InteropServices;
using System.Text;
using Microsoft.Win32.SafeHandles;
public static class PwfPlanListingNative {
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern SafeFileHandle CreateFileW(
string name, uint access, uint share, IntPtr security,
uint creation, uint flags, IntPtr template);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern uint GetFinalPathNameByHandleW(
SafeFileHandle handle, StringBuilder path, uint length, uint flags);
[DllImport("libc", SetLastError = true)]
private static extern IntPtr realpath(string path, IntPtr resolved);
[DllImport("libc")]
private static extern void free(IntPtr pointer);
public static string FinalPath(string path) {
if (Environment.OSVersion.Platform != PlatformID.Win32NT) {
IntPtr resolved = realpath(path, IntPtr.Zero);
if (resolved == IntPtr.Zero) throw new Win32Exception(Marshal.GetLastWin32Error());
try { return Marshal.PtrToStringAnsi(resolved); }
finally { free(resolved); }
}
// OPEN_EXISTING, FILE_SHARE_READ | WRITE | DELETE, BACKUP_SEMANTICS.
using (SafeFileHandle handle = CreateFileW(
path, 0, 7, IntPtr.Zero, 3, 0x02000000, IntPtr.Zero)) {
if (handle.IsInvalid) throw new Win32Exception(Marshal.GetLastWin32Error());
StringBuilder buffer = new StringBuilder(32768);
uint length = GetFinalPathNameByHandleW(handle, buffer, (uint)buffer.Capacity, 0);
if (length == 0 || length >= buffer.Capacity)
throw new Win32Exception(Marshal.GetLastWin32Error());
string result = buffer.ToString();
if (result.StartsWith(@"\\?\UNC\", StringComparison.OrdinalIgnoreCase))
return @"\\" + result.Substring(8);
if (result.StartsWith(@"\\?\", StringComparison.OrdinalIgnoreCase))
return result.Substring(4);
return result;
}
}
}
'@
}
return [PwfPlanListingNative]::FinalPath($Path)
}
function Test-WithinRoot {
param([string]$Path)
try {
$rootReal = (Get-FinalPath $ProjectRoot).TrimEnd('\', '/')
$pathReal = (Get-FinalPath $Path).TrimEnd('\', '/')
$comparison = [StringComparison]::Ordinal
if ($script:IsWindowsHost) { $comparison = [StringComparison]::OrdinalIgnoreCase }
return $pathReal.Equals($rootReal, $comparison) -or
$pathReal.StartsWith($rootReal + [IO.Path]::DirectorySeparatorChar, $comparison)
} catch {
return $false
}
}
function Test-ValidSlug {
param([string]$Name)
return $Name -cmatch '^[A-Za-z0-9_][A-Za-z0-9._-]*\z'
}
function Test-SafeActiveFile {
param([switch]$AllowLink)
$item = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
if (-not $item) { return $false }
# Reading may follow a verified in-project link; writing must not.
return -not $item.PSIsContainer -and
($AllowLink -or (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0)) -and
(Test-WithinRoot $ActiveFile)
}
function Get-CurrentActivePlan {
if (-not (Test-SafeActiveFile -AllowLink)) { return "" }
try {
$current = ([string](Get-Content -LiteralPath $ActiveFile -Raw -Encoding UTF8 -ErrorAction Stop)).Trim([char[]]"`r`n")
if (Test-ValidSlug $current) { return $current }
} catch { }
return ""
}
function Add-PhaseStatus {
param([hashtable]$Counts, $Phase)
if ($null -eq $Phase) { return }
$status = $Phase.Inline
if ($Phase.Primary) { $status = $Phase.Primary }
if ($status) { $Counts[$status]++ }
}
function Get-PhaseStatus {
param([string]$PlanFile)
$counts = @{ total = 0; complete = 0; in_progress = 0; pending = 0 }
$phase = $null
$fence = ""
$fenceLength = 0
# The shipped translations keep status identifiers in English. Unicode
# escapes preserve Windows PowerShell 5.1 compatibility without a BOM.
$phaseLabel = '(?:Phase|Fase|\u0627\u0644\u0645\u0631\u062d\u0644\u0629|\u9636\u6bb5|\u968e\u6bb5)'
$statusLabel = '(?:Status:|Estado:|\u0627\u0644\u062d\u0627\u0644\u0629:|\u72b6\u6001\uff1a|\u72c0\u614b\uff1a)'
# Read once; an empty file is a valid plan with zero phases.
$content = [string](Get-Content -LiteralPath $PlanFile -Raw -Encoding UTF8 -ErrorAction Stop)
foreach ($line in ($content -split '\r?\n')) {
if ($fence) {
$closing = '^ {0,3}' + [regex]::Escape($fence) + '{' + $fenceLength + ',}[ \t]*$'
if ($line -match $closing) { $fence = "" }
continue
}
if ($line -match '^ {0,3}(`{3,}|~{3,})') {
$fence = $Matches[1].Substring(0, 1)
$fenceLength = $Matches[1].Length
continue
}
if ($line -cmatch ('^ {0,3}###[ \t]+' + $phaseLabel + '[ \t]+[0-9]+(?:[^0-9A-Za-z_]|$)')) {
Add-PhaseStatus $counts $phase
$phase = @{ Primary = ""; Inline = "" }
$counts.total++
if ($line -cmatch '\[(complete|in_progress|pending)\]') {
$phase.Inline = $Matches[1]
}
continue
}
if ($line -match '^ {0,3}#{1,3}(?:[ \t]+|$)') {
Add-PhaseStatus $counts $phase
$phase = $null
continue
}
if ($null -ne $phase -and -not $phase.Primary -and
$line -cmatch ('^ {0,3}(?:-[ \t]+)?\*\*' + $statusLabel + '\*\*[ \t]+(complete|in_progress|pending)(?:[ \t]|$)')) {
$phase.Primary = $Matches[1]
}
}
Add-PhaseStatus $counts $phase
return "$($counts.complete)/$($counts.total) complete, $($counts.in_progress) in_progress, $($counts.pending) pending"
}
function Show-PlanList {
if (-not (Test-Path -LiteralPath $PlanRoot -PathType Container)) {
Write-Output "No planning directory found."
return
}
if (-not (Test-WithinRoot $PlanRoot)) {
Write-Error "Error: planning directory is outside the project or cannot be verified."
exit 1
}
$active = Get-CurrentActivePlan
Write-Output "Available plans:"
Write-Output "[active] marks the shared .active_plan pointer; listing does not bind this session."
$found = $false
foreach ($plan in (Get-ChildItem -LiteralPath $PlanRoot -Directory -ErrorAction SilentlyContinue | Sort-Object Name)) {
if (-not (Test-ValidSlug $plan.Name) -or -not (Test-WithinRoot $plan.FullName)) { continue }
$planFile = Join-Path $plan.FullName "task_plan.md"
if (-not (Test-Path -LiteralPath $planFile -PathType Leaf) -or -not (Test-WithinRoot $planFile)) { continue }
try { $status = Get-PhaseStatus $planFile } catch { continue }
$marker = ""
if ($plan.Name -ceq $active) { $marker = " [active]" }
Write-Output "- $($plan.Name)$marker - $status"
$found = $true
}
if (-not $found) { Write-Output "No named plans found." }
}
if ($Help -or $PlanId -eq "--help" -or $PlanId -eq "-h") {
Write-Output "Usage: set-active-plan.ps1 [-List|-l|--list|PLAN_ID]"
exit 0
}
if ($List -or $PlanId -eq "--list" -or $PlanId -eq "-l") {
if ($List -and $PlanId) {
Write-Error "Error: list plans or set PLAN_ID in separate calls."
exit 1
}
Show-PlanList
exit 0
}
if ($PlanId -eq "") {
if (Test-Path $ActiveFile) {
$current = (Get-Content $ActiveFile -Raw -Encoding UTF8).Trim()
$current = ""
if ((Test-Path -LiteralPath $PlanRoot -PathType Container) -and -not (Test-WithinRoot $PlanRoot)) {
Write-Error "Error: planning directory is outside the project or cannot be verified."
exit 1
}
if (Test-WithinRoot $PlanRoot) { $current = Get-CurrentActivePlan }
if ($current) {
$planDir = Join-Path $PlanRoot $current
if ($current -ne "" -and (Test-Path $planDir)) {
if ((Test-Path -LiteralPath $planDir -PathType Container) -and (Test-WithinRoot $planDir)) {
Write-Output "Active plan: $current"
Write-Output "Path: $planDir"
} elseif ($current -ne "") {
Write-Output "Active plan pointer: $current (directory not found - stale pointer)"
} else {
Write-Output "No active plan set."
Write-Output "Active plan pointer: $current (directory not found or outside project - stale pointer)"
}
} else {
Write-Output "No active plan set."
@@ -29,21 +247,53 @@ if ($PlanId -eq "") {
exit 0
}
if (-not (Test-ValidSlug $PlanId)) {
Write-Error "Error: invalid plan ID. Use letters, numbers, underscores, dots, or hyphens; start with a letter, number, or underscore."
exit 1
}
$PlanDir = Join-Path $PlanRoot $PlanId
if (-not (Test-Path $PlanDir)) {
if (-not (Test-Path -LiteralPath $PlanDir -PathType Container)) {
Write-Error "Error: plan directory not found: $PlanDir"
Write-Error "Run: init-session.sh `"$PlanId`" to create it, or check .planning\ for available plans."
exit 1
}
if (-not (Test-Path $PlanRoot)) {
New-Item -ItemType Directory -Path $PlanRoot -Force | Out-Null
if (-not (Test-WithinRoot $PlanRoot) -or -not (Test-WithinRoot $PlanDir)) {
Write-Error "Error: plan directory must remain within the project."
exit 1
}
$activeItem = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
if ($activeItem -and -not (Test-SafeActiveFile)) {
Write-Error "Error: the active plan pointer must be a regular file within the project."
exit 1
}
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($ActiveFile, $PlanId, $utf8NoBom)
# Replace the directory entry instead of truncating an existing inode: a
# hardlinked pointer must not overwrite another file. The exclusive temporary
# file lives beside the pointer, keeping replacement on the same filesystem.
$tempFile = Join-Path $PlanRoot ('.active_plan.' + [guid]::NewGuid().ToString('N') + '.tmp')
$createdTemp = $false
try {
$stream = [IO.File]::Open($tempFile, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
$createdTemp = $true
try {
$bytes = $utf8NoBom.GetBytes($PlanId)
$stream.Write($bytes, 0, $bytes.Length)
$stream.Flush()
} finally { $stream.Dispose() }
if ($activeItem) {
[IO.File]::Replace($tempFile, $ActiveFile, [NullString]::Value)
} else {
[IO.File]::Move($tempFile, $ActiveFile)
}
} catch {
Write-Error "Error: could not set the active plan pointer: $($_.Exception.Message)"
exit 1
} finally {
if ($createdTemp -and (Test-Path -LiteralPath $tempFile)) {
Remove-Item -LiteralPath $tempFile -Force -ErrorAction SilentlyContinue
}
}
Write-Output "Active plan set to: $PlanId"
Write-Output "Path: $PlanDir"
Write-Output ""
+323 -38
View File
@@ -1,50 +1,335 @@
#!/bin/sh
# planning-with-files: set or display the active plan pointer.
#
# Usage:
# set-active-plan.sh <plan_id> — pin .planning/.active_plan to plan_id
# set-active-plan.sh — print the current active plan (if any)
#
# The active plan is stored in .planning/.active_plan and is read by
# resolve-plan-dir.sh when no $PLAN_ID env var is set.
set -e
# List saved named plans, show the shared pointer, or change that pointer.
# Usage: set-active-plan.sh [--list|-l|PLAN_ID]
# Operates on the current project; listing never binds a host or injects data.
set -eu
PLAN_ROOT="${PWD}/.planning"
ACTIVE_FILE="${PLAN_ROOT}/.active_plan"
PWF_ROOT_PIN=""
# No args → show current active plan
if [ "${1:-}" = "" ]; then
if [ -f "${ACTIVE_FILE}" ]; then
plan_id="$(tr -d '\r\n' < "${ACTIVE_FILE}")"
if [ -n "${plan_id}" ] && [ -d "${PLAN_ROOT}/${plan_id}" ]; then
echo "Active plan: ${plan_id}"
echo "Path: ${PLAN_ROOT}/${plan_id}"
elif [ -n "${plan_id}" ]; then
echo "Active plan pointer: ${plan_id} (directory not found — stale pointer)"
else
echo "No active plan set."
fi
# Use the resolver canonicalization policy without running plan selection.
slug_is_valid() {
case "$1" in
'') return 1 ;;
*[!A-Za-z0-9._-]*) return 1 ;;
[A-Za-z0-9_]*) return 0 ;;
esac
return 1
}
# Pure-sh backslash-to-forward-slash normalizer; result lands in $NORM_OUT.
# Windows-native coreutils builds (e.g. C:\Program Files\coreutils on PATH
# ahead of Git's usr/bin) canonicalize MSYS-style /c/... input to C:\-style
# backslash output. The containment prefix match below is written with forward
# slashes, so without this normalization every canonical pair mismatches and
# resolution silently fails. On POSIX systems paths contain no backslash and
# this is the identity. A literal backslash in a Unix filename normalizes to
# "/" and at worst fails containment — the safe direction. No subshell, no
# fork: plain parameter expansion in a loop.
norm_slashes() {
NORM_OUT=""
_ns_rest="$1"
while :; do
case "${_ns_rest}" in
*\\*)
NORM_OUT="${NORM_OUT}${_ns_rest%%\\*}/"
_ns_rest="${_ns_rest#*\\}"
;;
*)
NORM_OUT="${NORM_OUT}${_ns_rest}"
break
;;
esac
done
}
# Return true when a candidate path names the Microsoft Store WindowsApps
# directory. Store app aliases are not stable interpreter binaries and may
# present as executable while refusing script execution. Matching is
# case-insensitive and works before or after Windows slash normalization.
is_windowsapps_path() {
norm_slashes "$1"
case "${NORM_OUT}" in
[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]|\
[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]/*|\
*/[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]|\
*/[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]/*) return 0 ;;
esac
return 1
}
# Select only an interpreter path the caller explicitly trusted.
# PWF_TRUSTED_PYTHON is preferred; PYTHON_BIN remains a compatibility alias.
# PATH discovery is intentionally forbidden because resolver hooks can run in
# repositories that control PATH. Windows-native absolute paths are converted
# with Git Bash's fixed system cygpath, never a PATH-selected shim.
trusted_python() {
for _tp_candidate in "${PWF_TRUSTED_PYTHON:-}" "${PYTHON_BIN:-}"; do
[ -n "${_tp_candidate}" ] || continue
case "${_tp_candidate}" in
\\\\*|//*) continue ;;
[A-Za-z]:[\\/]*)
is_windowsapps_path "${_tp_candidate}" && continue
_tp_cygpath="/usr/bin/cygpath.exe"
[ -f "${_tp_cygpath}" ] && [ -x "${_tp_cygpath}" ] || continue
_tp_candidate="$("${_tp_cygpath}" -u "${_tp_candidate}" 2>/dev/null)" \
|| continue
;;
/*) ;;
*) continue ;;
esac
is_windowsapps_path "${_tp_candidate}" && continue
[ -f "${_tp_candidate}" ] || continue
[ -x "${_tp_candidate}" ] || continue
printf "%s\n" "${_tp_candidate}"
return 0
done
return 1
}
# Portable path canonicalizer. realpath first (Linux, modern coreutils),
# then readlink -f (older GNU), then an explicitly trusted Python interpreter.
# Prints the canonical absolute path on success; prints nothing and returns 1
# on a full miss so containment fails closed. No Python spawn on the happy
# path: realpath/readlink cover Linux, WSL, Git-Bash, and modern macOS.
canonicalize() {
target="$1"
if command -v realpath >/dev/null 2>&1; then
out="$(realpath "${target}" 2>/dev/null)" && [ -n "${out}" ] && {
printf "%s\n" "${out}"; return 0; }
fi
if command -v readlink >/dev/null 2>&1; then
out="$(readlink -f "${target}" 2>/dev/null)" && [ -n "${out}" ] && {
printf "%s\n" "${out}"; return 0; }
fi
_canonical_python="$(trusted_python)" || _canonical_python=""
if [ -n "${_canonical_python}" ]; then
out="$("${_canonical_python}" -I -c "import os,sys;print(os.path.realpath(sys.argv[1]))" "${target}" 2>/dev/null)" \
&& [ -n "${out}" ] && { printf "%s\n" "${out}"; return 0; }
fi
return 1
}
# Containment guard (security A1.3): a resolved plan dir must canonicalize to a
# path under the project root (the CWD the script runs from). A symlink inside
# a valid slug dir pointing at /etc or outside the workspace would otherwise let
# the hooks hash and inject an arbitrary file. On any violation we return 1 so
# the caller treats the candidate as unresolved and falls back safely.
#
# The root canonicalizes via the relative token "." rather than the $PWD
# string. On some Windows/MSYS setups (8.3 short names, the /tmp mount alias)
# realpath("$PWD") and realpath(relative-candidate) resolve through different
# code paths and land on differently-spelled-but-equal targets, so the prefix
# match below fails and resolution silently goes dark. "." resolves through
# the same physical-cwd path candidates already use (same fix inject-plan.sh
# received earlier; the resolver kept the $PWD form until now). Both sides are
# backslash-normalized before comparison for Windows-native canonicalizers.
# The root is computed once per run: the newest-mtime scan calls this guard
# per plan dir, and each canonicalize costs a process spawn on Windows.
#
# With a PWF_PLAN_ROOT pin (issue #212) containment is checked against THAT
# root instead of the cwd: candidates arrive ${PWF_PLAN_ROOT}/-prefixed, so
# both sides canonicalize through the same path spelling. Unpinned keeps the
# relative "." root — byte-identical to the legacy check.
ROOT_REAL=""
ROOT_REAL_SET=0
is_within_root() {
candidate="$1"
if [ "${ROOT_REAL_SET}" = "0" ]; then
ROOT_REAL="$(canonicalize "${PWF_ROOT_PIN:-.}")" || ROOT_REAL=""
norm_slashes "${ROOT_REAL}"
ROOT_REAL="${NORM_OUT}"
ROOT_REAL_SET=1
fi
# Canonicalize the candidate through its cwd-RELATIVE form whenever it
# lives under ${PWD}. The candidate string is built from ${PWD} (an MSYS
# long-form spelling), while the root canonicalizes from "." (the process
# cwd, which a caller may have set with an 8.3 short-form string). A
# Windows-native realpath does not unify those spellings, so canonicalizing
# both sides from the same cwd base is the only spelling-stable comparison.
# The emitted result keeps the original absolute candidate — only the
# containment check uses the relative form.
# Pinned resolution skips the rewrite: candidate and root then share the
# ${PWF_PLAN_ROOT} spelling, so both canonicalize directly from it.
if [ -n "${PWF_ROOT_PIN}" ]; then
check_target="${candidate}"
else
echo "No active plan set."
case "${candidate}" in
"${PWD}"/*) check_target=".${candidate#"${PWD}"}" ;;
*) check_target="${candidate}" ;;
esac
fi
cand_real="$(canonicalize "${check_target}")" || cand_real=""
norm_slashes "${cand_real}"
cand_real="${NORM_OUT}"
if [ -z "${ROOT_REAL}" ] || [ -z "${cand_real}" ]; then
# Slug validation blocks textual traversal, but only successful
# canonicalization can rule out a symlink/junction escape.
return 1
fi
case "${cand_real}" in
"${ROOT_REAL}"|"${ROOT_REAL}"/*) return 0 ;;
*) return 1 ;;
esac
}
# Each phase contributes at most one status. An explicit Status line wins
# over an inline heading marker. Fenced examples and unrelated sections are
# not phases. Keep this parser aligned with the PowerShell helper.
phase_status() {
awk '
function finish() {
if (phase) {
status = primary != "" ? primary : inline_status
if (status == "complete") complete++
else if (status == "in_progress") in_progress++
else if (status == "pending") pending++
}
phase = 0; primary = ""; inline_status = ""
}
{
line = $0
sub(/\r$/, "", line)
trimmed = line
sub(/^ */, "", trimmed)
if (line ~ /^ ? ? ?```/ || line ~ /^ ? ? ?~~~/) {
marker = substr(trimmed, 1, 1)
run = 0
while (substr(trimmed, run + 1, 1) == marker) run++
if (fence == "") { fence = marker; fence_length = run }
else if (marker == fence && run >= fence_length && substr(trimmed, run + 1) ~ /^[ \t]*$/) fence = ""
next
}
if (fence != "") next
if (line ~ /^ ? ? ?###[ \t]+(Phase|Fase|المرحلة|阶段|階段)[ \t]+[0-9]+([^0-9A-Za-z_]|$)/) {
finish(); phase = 1; total++
if (match(line, /\[(complete|in_progress|pending)\]/))
inline_status = substr(line, RSTART + 1, RLENGTH - 2)
} else if (line ~ /^ ? ? ?(###|##|#)([ \t]|$)/) {
finish()
} else if (phase && primary == "" && line ~ /^ ? ? ?(-[ \t]+)?\*\*(Status:|Estado:|الحالة:|状态:|狀態:)\*\*[ \t]+(complete|in_progress|pending)([ \t]|$)/) {
sub(/^ ? ? ?(-[ \t]+)?\*\*(Status:|Estado:|الحالة:|状态:|狀態:)\*\*[ \t]+/, "", line)
sub(/[ \t].*$/, "", line)
primary = line
}
}
END {
finish()
printf "%d/%d complete, %d in_progress, %d pending", complete, total, in_progress, pending
}
' < "$1"
}
current_active() {
if [ -f "${ACTIVE_FILE}" ] && is_within_root "${ACTIVE_FILE}"; then
_current="$(tr '\r' '\n' < "${ACTIVE_FILE}")"
# Windows editors and older PowerShell defaults can leave a UTF-8 BOM.
# Treat it as an encoding marker, not part of the shared plan slug.
_utf8_bom="$(printf '\357\273\277')"
case "${_current}" in
"${_utf8_bom}"*) _current="${_current#"${_utf8_bom}"}" ;;
esac
slug_is_valid "${_current}" && printf '%s\n' "${_current}"
fi
return 0
}
list_plans() {
if [ ! -d "${PLAN_ROOT}" ]; then
printf '%s\n' 'No planning directory found.'
return 0
fi
if ! is_within_root "${PLAN_ROOT}"; then
printf '%s\n' 'Error: planning directory is outside the project or cannot be verified.' >&2
return 1
fi
_active="$(current_active)"
_found=0
printf '%s\n' 'Available plans:'
for _dir in "${PLAN_ROOT}"/*; do
[ -d "${_dir}" ] || continue
_id="${_dir##*/}"
slug_is_valid "${_id}" || continue
is_within_root "${_dir}" || continue
_plan_file="${_dir}/task_plan.md"
[ -f "${_plan_file}" ] && [ -r "${_plan_file}" ] || continue
is_within_root "${_plan_file}" || continue
_status="$(phase_status "${_plan_file}")" || continue
_marker=''
if [ "${_id}" = "${_active}" ]; then _marker=' [active]'; fi
printf '%s\n' "- ${_id}${_marker} - ${_status}"
_found=1
done
if [ "${_found}" -eq 0 ]; then
printf '%s\n' 'No named plans found.'
else
printf '%s\n' '[active] marks the shared default pointer. Set PLAN_ID to pin a session.'
fi
}
if [ "$#" -gt 1 ]; then
printf '%s\n' 'Error: list plans or set PLAN_ID in separate calls.' >&2
exit 1
fi
case "${1:-}" in
--list|-l) list_plans; exit $? ;;
--help|-h)
printf '%s\n' 'Usage: set-active-plan.sh [--list|PLAN_ID]' \
'Lists saved named plans in the current project without selecting a plan.'
exit 0 ;;
esac
if [ "${1:-}" = '' ]; then
if [ -d "${PLAN_ROOT}" ] && ! is_within_root "${PLAN_ROOT}"; then
printf '%s\n' 'Error: planning directory is outside the project or cannot be verified.' >&2
exit 1
fi
plan_id="$(current_active)"
if [ -n "${plan_id}" ] && [ -d "${PLAN_ROOT}/${plan_id}" ] && is_within_root "${PLAN_ROOT}/${plan_id}"; then
printf '%s\n' "Active plan: ${plan_id}" "Path: ${PLAN_ROOT}/${plan_id}"
elif [ -n "${plan_id}" ]; then
printf '%s\n' "Active plan pointer: ${plan_id} (directory not found or outside project - stale pointer)"
else
printf '%s\n' 'No active plan set.'
fi
exit 0
fi
PLAN_ID="$1"
PLAN_DIR="${PLAN_ROOT}/${PLAN_ID}"
if [ ! -d "${PLAN_DIR}" ]; then
echo "Error: plan directory not found: ${PLAN_DIR}" >&2
echo "Run: init-session.sh \"${PLAN_ID}\" to create it, or check .planning/ for available plans." >&2
if ! slug_is_valid "${PLAN_ID}"; then
printf '%s\n' 'Error: invalid plan ID. Use a named directory under .planning.' >&2
exit 1
fi
mkdir -p "${PLAN_ROOT}"
printf "%s\n" "${PLAN_ID}" > "${ACTIVE_FILE}"
echo "Active plan set to: ${PLAN_ID}"
echo "Path: ${PLAN_DIR}"
echo ""
echo "To pin this terminal session only:"
echo " export PLAN_ID=${PLAN_ID}"
PLAN_DIR="${PLAN_ROOT}/${PLAN_ID}"
if [ ! -d "${PLAN_DIR}" ]; then
printf '%s\n' "Error: plan directory not found: ${PLAN_DIR}" \
"Run: init-session.sh \"${PLAN_ID}\" to create it, or use --list to see available plans." >&2
exit 1
fi
if ! is_within_root "${PLAN_ROOT}" || ! is_within_root "${PLAN_DIR}"; then
printf '%s\n' 'Error: plan directory is outside the project or cannot be verified.' >&2
exit 1
fi
# A pre-existing pointer must itself be a contained regular file before writing.
if { [ -e "${ACTIVE_FILE}" ] || [ -L "${ACTIVE_FILE}" ]; } &&
{ [ -L "${ACTIVE_FILE}" ] || [ ! -f "${ACTIVE_FILE}" ] || ! is_within_root "${ACTIVE_FILE}"; }; then
printf '%s\n' 'Error: active plan pointer is not a safe file inside the project.' >&2
exit 1
fi
# Replace the pointer atomically instead of truncating a possible hardlink.
# mktemp creates a private, exclusive file beside the destination.
temp_file="$(mktemp "${PLAN_ROOT}/.active_plan.XXXXXX")" || {
printf '%s\n' 'Error: could not create the active plan pointer.' >&2
exit 1
}
trap 'rm -f "${temp_file}"' EXIT
trap 'exit 1' HUP INT TERM
printf '%s\n' "${PLAN_ID}" > "${temp_file}"
if ! mv -f "${temp_file}" "${ACTIVE_FILE}"; then
printf '%s\n' 'Error: could not replace the active plan pointer.' >&2
exit 1
fi
trap - EXIT HUP INT TERM
printf '%s\n' "Active plan set to: ${PLAN_ID}" "Path: ${PLAN_DIR}" '' \
'To pin this terminal session only:' " export PLAN_ID=${PLAN_ID}"
+37
View File
@@ -4,6 +4,43 @@ All notable changes to this project will be documented in this file.
## [Unreleased]
## [3.18.2] - 2026-09-16
### Fixed
- IDE sync verification now fails when an enabled mapping has no canonical source, reports every affected entry, and continues checking the remaining mappings. Verification remains read-only (#244).
### Security
- Run Python in isolated mode in the Codex, Gemini, and GitHub Copilot shell adapters. Project-local modules and Python environment overrides can no longer shadow standard-library imports in these calls. Explicit UTF-8 mode preserves Unicode project paths and error messages on Windows (#245).
### Thanks
- @kuei51307-hub, for missing-source detection and CLI regression coverage in #244.
- @ShaunLinTW, for isolating adapter Python calls and adding import-shadowing regression coverage in #245.
## [3.18.1] - 2026-09-15
### Fixed
- Accept a leading UTF-8 BOM in `.planning/.active_plan` when showing the current plan or listing saved plans. This restores the active marker for pointers written by Windows editors and PowerShell UTF-8 workflows. The fix ships across all canonical shell helper copies (#243).
### Thanks
- @kuei51307-hub, for the BOM compatibility fix and regression coverage in #243.
## [3.18.0] - 2026-09-13
### Added
- List saved named plans with `set-active-plan.sh --list` or PowerShell `set-active-plan.ps1 -List`. The output shows plan IDs, phase counts, and the shared default pointer without selecting a plan or attaching a session (#242).
- The canonical helpers and usage instructions ship across the supported IDE and language bundles, including OpenCode, Mastracode, and Hermes. Kiro's separate `.kiro/plan` layout is documented as outside this inventory.
### Fixed
- Count each phase once across mixed inline and explicit status formats, including the shipped translated templates. Ignore fenced examples, indented status examples, and unrelated sections.
- Support native PowerShell file invocation, empty files, and Windows PowerShell 5.1 startup from project paths containing brackets.
### Security
- Validate plan IDs and canonicalize the planning directory, listed directories, plan files, and pointer before reading. Symlink and junction paths outside the current project are rejected.
- Replace the shared pointer atomically, preserving external hardlinked files. A failed write returns an error without reporting that the plan was selected.
### Thanks
- @Dphoshoba, for the original implementation and plan-listing proposal in #242.
## [3.17.2] - 2026-09-09
### Fixed
+2 -2
View File
@@ -7,8 +7,8 @@ authors:
- family-names: Adi
given-names: Ahmad Othman Ammar
alias: OthmanAdi
version: "3.17.2"
date-released: "2026-09-09"
version: "3.18.2"
date-released: "2026-09-16"
license: MIT
url: "https://github.com/OthmanAdi/planning-with-files"
repository-code: "https://github.com/OthmanAdi/planning-with-files"
+9 -1
View File
@@ -32,7 +32,15 @@ Before opening a pull request, run the test suite:
python -m pytest tests/ -q
```
Two pre-existing Windows exec-bit test failures may appear on Windows. If those are the only failures, note that in your pull request description.
Investigate test failures and include any unresolved findings in your pull request description.
## CI completion rule
Maintainer delivery is complete only after all five `Tests` jobs succeed on the final commit pushed to `master`: pytest on Linux, Windows and macOS, plus the Pi extension and OpenCode plugin suites.
After the final push, verify the remote `master` commit and wait for its workflow to finish. Queued, running, cancelled, timed-out, failed or missing checks mean verification is unfinished. Continue investigating, fixing and rerunning until every job is green. Preserve test coverage and security checks; change runtime limits only when measured runtime justifies a bounded adjustment.
A passing candidate branch or an identical Git tree does not replace checks on the final pushed commit. Recheck remote `master` and its check results immediately before reporting completion. If the user explicitly stops the work or an external blocker prevents progress, record the unfinished checks and the blocker.
## Project layout
+12 -2
View File
@@ -106,6 +106,16 @@ These amazing people have contributed code, documentation, or significant improv
### Other Contributors
- **[Shaun Lin](https://github.com/ShaunLinTW)**, [PR #245](https://github.com/OthmanAdi/planning-with-files/pull/245)
- Isolated Python calls in the Codex, Gemini, and GitHub Copilot shell adapters and added regression coverage for project-local import shadowing.
- **[@kuei51307-hub](https://github.com/kuei51307-hub)**, [PR #243](https://github.com/OthmanAdi/planning-with-files/pull/243), [PR #244](https://github.com/OthmanAdi/planning-with-files/pull/244)
- Fixed active-plan display and listing for UTF-8 BOM-prefixed pointers, with regression coverage across the synchronized shell helpers.
- Made IDE sync verification report missing canonical sources, with subprocess tests for read-only verification and unchanged sync behavior.
- **[@Dphoshoba](https://github.com/Dphoshoba)**, [PR #242](https://github.com/OthmanAdi/planning-with-files/pull/242)
- Proposed and implemented named-plan listing with phase counts and a shared active-pointer marker.
- **[@sunznx](https://github.com/sunznx)**, [Issue #240](https://github.com/OthmanAdi/planning-with-files/issues/240), [Issue #241](https://github.com/OthmanAdi/planning-with-files/issues/241)
- Reported and reproduced named-plan crossover between Codex sessions sharing a working directory after compaction.
- Reported the 13 duplicate Codex command skills, traced the legacy command fallback, and proposed the native manifest setting that disables it.
@@ -425,6 +435,6 @@ If you've contributed and don't see your name here, please open an issue! We wan
---
**Total Contributors:** 59+ and growing!
**Total Contributors:** 62+ and growing!
*Last updated: 2026-09-09*
*Last updated: 2026-09-16*
+9 -2
View File
@@ -2,7 +2,9 @@
<img src="media/v3-banner-1400.jpg" alt="planning-with-files: task_plan.md, findings.md, and progress.md as three stone tablets" width="100%">
</div>
<h1 align="center">Planning with Files</h1>
<h1 align="center">
Planning with Files&nbsp;&nbsp;&nbsp;<a href="https://trendshift.io/repositories/17191" title="Trendshift: #1 daily across all languages, January 6, 2026"><img src="https://trendshift.io/api/badge/trendshift/repositories/17191/daily" alt="Trendshift: #1 Repository of the Day across all languages" width="250" height="55" align="middle"></a>
</h1>
<p align="center">
<strong>The planning skill your agent cannot ignore.</strong><br>
@@ -354,6 +356,9 @@ One hook fire measures 289ms wall-clock since the v3.6.0 optimization, down from
| Version | Highlights |
|---------|------------|
| **v3.18.2** | Isolates Python in Codex, Gemini, and Copilot shell adapters and makes IDE sync verification fail on missing canonical sources (#244, #245). |
| **v3.18.1** | Fixes active-plan display and listing for UTF-8 BOM-prefixed pointers from Windows editors and PowerShell workflows across the canonical shell helpers (#243). |
| **v3.18.0** | Lists saved plans and phase counts with `--list` or PowerShell `-List` (#242). Supports the shipped translated templates, checks project containment, and delivers the canonical helpers across IDE bundles. |
| **v3.17.2** | Fixes #241: the native Codex manifest disables legacy command migration, removing 13 redundant `source-command-*` skills from plugin installs. The canonical planning skill, Codex hooks, and Claude commands remain available. |
| **v3.17.1** | Fixes #240: two named plans in the same project now require `PLAN_ID`, even without `.planning/sessions/`. A shared pointer or newest-plan guess cannot redirect a Codex session across compaction. Ambiguous hooks inject no plan and Stop does not gate against a guessed plan. |
| **v3.17.0** | **Every Claude Code hook fire forked about 130 processes, and under Git Bash on Windows that took 7 to 12 seconds against the 10 second hook timeout.** Claude Code discarded the plan context ("UserPromptSubmit hook timed out after 10s") and every Bash, Read, Grep and Edit call waited 5 more seconds in PreToolUse before it ran. Linux and macOS never showed it because a fork costs milliseconds there. The events now run in one Python process, `scripts/inject-plan.py`, a byte-identical twin of the shell chain proven by a parity suite on all three CI legs, with the shell chain kept as the reference and as the fallback for hosts without Python: 0.3 s per prompt and per tool call on the reporting machine. Hook interpreters now start in isolated mode, so a repository's own `secrets.py` or `hashlib.py` is never imported by a hook. `PWF_FAST_PATH=0` forces the shell chain. |
@@ -514,7 +519,7 @@ Hermes' own `skills-guard` scanner rates the Hermes bundle `SAFE`; the canonical
> **Markdown on disk is the shared state between agents.** One orchestrator owns `task_plan.md` and the shared summaries; every worker appends to its own ledger or assigned file. Pin each independent task with `PLAN_ID` before starting its host, or use separate worktrees.
- **Run ledger per agent.** Workers append one JSON line per event to `.planning/<id>/ledger-<agent>.jsonl` (`ledger-append.sh`); `ledger-summary.sh` synthesizes a fixed-shape, KV-cache-stable block from all ledgers that replaces the raw `progress.md` tail in autonomous and gated mode. No free text from disk reaches the model through that block.
- **Plan isolation per task.** `init-session.sh "<name>"` gives each parallel task its own `.planning/YYYY-MM-DD-<slug>/` directory; `PLAN_ID` pins a terminal to one of them, `set-active-plan.sh` switches the shared pointer.
- **Plan isolation per task.** `init-session.sh "<name>"` gives each parallel task its own `.planning/YYYY-MM-DD-<slug>/` directory; `PLAN_ID` pins a terminal to one of them, `set-active-plan.sh --list` shows available plans and phase counts, and `set-active-plan.sh <id>` switches the shared pointer.
- **Threads whose cwd is a shared parent.** `PWF_PLAN_ROOT=<absolute path>` binds an agent thread to the project that owns the plan; an ambiguous cwd, where a nested project carries its own planning state, injects nothing rather than guessing.
- **Session attachment.** An `.attached` marker authorizes context but does not select a task. In the Codex, Hermes, Pi, and standalone hook routes, armed isolation with multiple plans requires `PLAN_ID`; otherwise context is refused. A project-root pin alone cannot distinguish tasks within that root.
- **Parallel-write guard.** The next turn warns if checked items or completed phases decrease. This is an advisory check after the write, not a lock or merge mechanism. It does not detect every overwritten plan, `progress.md`, or `findings.md`.
@@ -523,6 +528,8 @@ Hermes' own `skills-guard` scanner rates the Hermes bundle `SAFE`; the canonical
The contract and the `.mode` tokens are specified in the skill itself ([SKILL.md, Autonomous and Gated Modes](skills/planning-with-files/SKILL.md#autonomous-and-gated-modes-v3)) and in [docs/long-running-agent-tasks.md](docs/long-running-agent-tasks.md).
To find a saved plan, run the installed `scripts/set-active-plan.sh --list` helper from your project directory, or `scripts/set-active-plan.ps1 -List` in PowerShell. It lists named plans under that project's `.planning/` directory, with phase counts for the shipped English and translated templates. `[active]` marks the shared default pointer. Listing does not select a plan or attach a session; use the displayed ID as `PLAN_ID` before starting a concurrent task. Kiro's separate `.kiro/plan` layout is not part of this inventory.
## How It Works
The agent stops at the first rung that applies:
+266 -16
View File
@@ -1,27 +1,245 @@
# planning-with-files: set or display the active plan pointer (PowerShell).
# planning-with-files: set, display, or list the active plan pointer (PowerShell).
#
# Usage:
# .\set-active-plan.ps1 <plan_id> - pin .planning\.active_plan to plan_id
# .\set-active-plan.ps1 - print the current active plan (if any)
# .\set-active-plan.ps1 -List - list available named plans and phase counts
# .\set-active-plan.ps1 --list - equivalent to -List and -l
param(
[string]$PlanId = ""
[Parameter(Position = 0)]
[string]$PlanId = "",
[Alias('l', '-list')]
[switch]$List,
[Alias('h', '-help')]
[switch]$Help
)
$PlanRoot = Join-Path (Get-Location) ".planning"
$ProjectRoot = (Get-Location).Path
# Windows PowerShell 5.1 can silently change directories when -File inherits
# a cwd containing wildcard characters. Recover that physical cwd only for a
# direct invocation of this script; interactive Set-Location remains primary.
if ($PSVersionTable.PSVersion.Major -eq 5 -and
[System.Management.Automation.WildcardPattern]::ContainsWildcardCharacters([Environment]::CurrentDirectory)) {
$processArgs = [Environment]::GetCommandLineArgs()
for ($index = 0; $index -lt ($processArgs.Length - 1); $index++) {
if ($processArgs[$index] -ieq '-File') {
$entryScript = $processArgs[$index + 1]
if (-not [IO.Path]::IsPathRooted($entryScript)) {
$entryScript = Join-Path ([Environment]::CurrentDirectory) $entryScript
}
if ([IO.Path]::GetFullPath($entryScript) -eq [IO.Path]::GetFullPath($PSCommandPath)) {
$ProjectRoot = [Environment]::CurrentDirectory
}
break
}
}
}
$PlanRoot = Join-Path $ProjectRoot ".planning"
$ActiveFile = Join-Path $PlanRoot ".active_plan"
$script:IsWindowsHost = [Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT
# Resolve-Path can retain a junction's spelling. Like resolve-plan-dir.ps1,
# open the actual filesystem object before checking the project boundary.
# The Windows handle supports both directories and task_plan.md files.
function Get-FinalPath {
param([string]$Path)
if (-not ("PwfPlanListingNative" -as [type])) {
Add-Type -ErrorAction Stop -TypeDefinition @'
using System;
using System.ComponentModel;
using System.Runtime.InteropServices;
using System.Text;
using Microsoft.Win32.SafeHandles;
public static class PwfPlanListingNative {
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern SafeFileHandle CreateFileW(
string name, uint access, uint share, IntPtr security,
uint creation, uint flags, IntPtr template);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern uint GetFinalPathNameByHandleW(
SafeFileHandle handle, StringBuilder path, uint length, uint flags);
[DllImport("libc", SetLastError = true)]
private static extern IntPtr realpath(string path, IntPtr resolved);
[DllImport("libc")]
private static extern void free(IntPtr pointer);
public static string FinalPath(string path) {
if (Environment.OSVersion.Platform != PlatformID.Win32NT) {
IntPtr resolved = realpath(path, IntPtr.Zero);
if (resolved == IntPtr.Zero) throw new Win32Exception(Marshal.GetLastWin32Error());
try { return Marshal.PtrToStringAnsi(resolved); }
finally { free(resolved); }
}
// OPEN_EXISTING, FILE_SHARE_READ | WRITE | DELETE, BACKUP_SEMANTICS.
using (SafeFileHandle handle = CreateFileW(
path, 0, 7, IntPtr.Zero, 3, 0x02000000, IntPtr.Zero)) {
if (handle.IsInvalid) throw new Win32Exception(Marshal.GetLastWin32Error());
StringBuilder buffer = new StringBuilder(32768);
uint length = GetFinalPathNameByHandleW(handle, buffer, (uint)buffer.Capacity, 0);
if (length == 0 || length >= buffer.Capacity)
throw new Win32Exception(Marshal.GetLastWin32Error());
string result = buffer.ToString();
if (result.StartsWith(@"\\?\UNC\", StringComparison.OrdinalIgnoreCase))
return @"\\" + result.Substring(8);
if (result.StartsWith(@"\\?\", StringComparison.OrdinalIgnoreCase))
return result.Substring(4);
return result;
}
}
}
'@
}
return [PwfPlanListingNative]::FinalPath($Path)
}
function Test-WithinRoot {
param([string]$Path)
try {
$rootReal = (Get-FinalPath $ProjectRoot).TrimEnd('\', '/')
$pathReal = (Get-FinalPath $Path).TrimEnd('\', '/')
$comparison = [StringComparison]::Ordinal
if ($script:IsWindowsHost) { $comparison = [StringComparison]::OrdinalIgnoreCase }
return $pathReal.Equals($rootReal, $comparison) -or
$pathReal.StartsWith($rootReal + [IO.Path]::DirectorySeparatorChar, $comparison)
} catch {
return $false
}
}
function Test-ValidSlug {
param([string]$Name)
return $Name -cmatch '^[A-Za-z0-9_][A-Za-z0-9._-]*\z'
}
function Test-SafeActiveFile {
param([switch]$AllowLink)
$item = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
if (-not $item) { return $false }
# Reading may follow a verified in-project link; writing must not.
return -not $item.PSIsContainer -and
($AllowLink -or (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0)) -and
(Test-WithinRoot $ActiveFile)
}
function Get-CurrentActivePlan {
if (-not (Test-SafeActiveFile -AllowLink)) { return "" }
try {
$current = ([string](Get-Content -LiteralPath $ActiveFile -Raw -Encoding UTF8 -ErrorAction Stop)).Trim([char[]]"`r`n")
if (Test-ValidSlug $current) { return $current }
} catch { }
return ""
}
function Add-PhaseStatus {
param([hashtable]$Counts, $Phase)
if ($null -eq $Phase) { return }
$status = $Phase.Inline
if ($Phase.Primary) { $status = $Phase.Primary }
if ($status) { $Counts[$status]++ }
}
function Get-PhaseStatus {
param([string]$PlanFile)
$counts = @{ total = 0; complete = 0; in_progress = 0; pending = 0 }
$phase = $null
$fence = ""
$fenceLength = 0
# The shipped translations keep status identifiers in English. Unicode
# escapes preserve Windows PowerShell 5.1 compatibility without a BOM.
$phaseLabel = '(?:Phase|Fase|\u0627\u0644\u0645\u0631\u062d\u0644\u0629|\u9636\u6bb5|\u968e\u6bb5)'
$statusLabel = '(?:Status:|Estado:|\u0627\u0644\u062d\u0627\u0644\u0629:|\u72b6\u6001\uff1a|\u72c0\u614b\uff1a)'
# Read once; an empty file is a valid plan with zero phases.
$content = [string](Get-Content -LiteralPath $PlanFile -Raw -Encoding UTF8 -ErrorAction Stop)
foreach ($line in ($content -split '\r?\n')) {
if ($fence) {
$closing = '^ {0,3}' + [regex]::Escape($fence) + '{' + $fenceLength + ',}[ \t]*$'
if ($line -match $closing) { $fence = "" }
continue
}
if ($line -match '^ {0,3}(`{3,}|~{3,})') {
$fence = $Matches[1].Substring(0, 1)
$fenceLength = $Matches[1].Length
continue
}
if ($line -cmatch ('^ {0,3}###[ \t]+' + $phaseLabel + '[ \t]+[0-9]+(?:[^0-9A-Za-z_]|$)')) {
Add-PhaseStatus $counts $phase
$phase = @{ Primary = ""; Inline = "" }
$counts.total++
if ($line -cmatch '\[(complete|in_progress|pending)\]') {
$phase.Inline = $Matches[1]
}
continue
}
if ($line -match '^ {0,3}#{1,3}(?:[ \t]+|$)') {
Add-PhaseStatus $counts $phase
$phase = $null
continue
}
if ($null -ne $phase -and -not $phase.Primary -and
$line -cmatch ('^ {0,3}(?:-[ \t]+)?\*\*' + $statusLabel + '\*\*[ \t]+(complete|in_progress|pending)(?:[ \t]|$)')) {
$phase.Primary = $Matches[1]
}
}
Add-PhaseStatus $counts $phase
return "$($counts.complete)/$($counts.total) complete, $($counts.in_progress) in_progress, $($counts.pending) pending"
}
function Show-PlanList {
if (-not (Test-Path -LiteralPath $PlanRoot -PathType Container)) {
Write-Output "No planning directory found."
return
}
if (-not (Test-WithinRoot $PlanRoot)) {
Write-Error "Error: planning directory is outside the project or cannot be verified."
exit 1
}
$active = Get-CurrentActivePlan
Write-Output "Available plans:"
Write-Output "[active] marks the shared .active_plan pointer; listing does not bind this session."
$found = $false
foreach ($plan in (Get-ChildItem -LiteralPath $PlanRoot -Directory -ErrorAction SilentlyContinue | Sort-Object Name)) {
if (-not (Test-ValidSlug $plan.Name) -or -not (Test-WithinRoot $plan.FullName)) { continue }
$planFile = Join-Path $plan.FullName "task_plan.md"
if (-not (Test-Path -LiteralPath $planFile -PathType Leaf) -or -not (Test-WithinRoot $planFile)) { continue }
try { $status = Get-PhaseStatus $planFile } catch { continue }
$marker = ""
if ($plan.Name -ceq $active) { $marker = " [active]" }
Write-Output "- $($plan.Name)$marker - $status"
$found = $true
}
if (-not $found) { Write-Output "No named plans found." }
}
if ($Help -or $PlanId -eq "--help" -or $PlanId -eq "-h") {
Write-Output "Usage: set-active-plan.ps1 [-List|-l|--list|PLAN_ID]"
exit 0
}
if ($List -or $PlanId -eq "--list" -or $PlanId -eq "-l") {
if ($List -and $PlanId) {
Write-Error "Error: list plans or set PLAN_ID in separate calls."
exit 1
}
Show-PlanList
exit 0
}
if ($PlanId -eq "") {
if (Test-Path $ActiveFile) {
$current = (Get-Content $ActiveFile -Raw -Encoding UTF8).Trim()
$current = ""
if ((Test-Path -LiteralPath $PlanRoot -PathType Container) -and -not (Test-WithinRoot $PlanRoot)) {
Write-Error "Error: planning directory is outside the project or cannot be verified."
exit 1
}
if (Test-WithinRoot $PlanRoot) { $current = Get-CurrentActivePlan }
if ($current) {
$planDir = Join-Path $PlanRoot $current
if ($current -ne "" -and (Test-Path $planDir)) {
if ((Test-Path -LiteralPath $planDir -PathType Container) -and (Test-WithinRoot $planDir)) {
Write-Output "Active plan: $current"
Write-Output "Path: $planDir"
} elseif ($current -ne "") {
Write-Output "Active plan pointer: $current (directory not found - stale pointer)"
} else {
Write-Output "No active plan set."
Write-Output "Active plan pointer: $current (directory not found or outside project - stale pointer)"
}
} else {
Write-Output "No active plan set."
@@ -29,21 +247,53 @@ if ($PlanId -eq "") {
exit 0
}
if (-not (Test-ValidSlug $PlanId)) {
Write-Error "Error: invalid plan ID. Use letters, numbers, underscores, dots, or hyphens; start with a letter, number, or underscore."
exit 1
}
$PlanDir = Join-Path $PlanRoot $PlanId
if (-not (Test-Path $PlanDir)) {
if (-not (Test-Path -LiteralPath $PlanDir -PathType Container)) {
Write-Error "Error: plan directory not found: $PlanDir"
Write-Error "Run: init-session.sh `"$PlanId`" to create it, or check .planning\ for available plans."
exit 1
}
if (-not (Test-Path $PlanRoot)) {
New-Item -ItemType Directory -Path $PlanRoot -Force | Out-Null
if (-not (Test-WithinRoot $PlanRoot) -or -not (Test-WithinRoot $PlanDir)) {
Write-Error "Error: plan directory must remain within the project."
exit 1
}
$activeItem = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
if ($activeItem -and -not (Test-SafeActiveFile)) {
Write-Error "Error: the active plan pointer must be a regular file within the project."
exit 1
}
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($ActiveFile, $PlanId, $utf8NoBom)
# Replace the directory entry instead of truncating an existing inode: a
# hardlinked pointer must not overwrite another file. The exclusive temporary
# file lives beside the pointer, keeping replacement on the same filesystem.
$tempFile = Join-Path $PlanRoot ('.active_plan.' + [guid]::NewGuid().ToString('N') + '.tmp')
$createdTemp = $false
try {
$stream = [IO.File]::Open($tempFile, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
$createdTemp = $true
try {
$bytes = $utf8NoBom.GetBytes($PlanId)
$stream.Write($bytes, 0, $bytes.Length)
$stream.Flush()
} finally { $stream.Dispose() }
if ($activeItem) {
[IO.File]::Replace($tempFile, $ActiveFile, [NullString]::Value)
} else {
[IO.File]::Move($tempFile, $ActiveFile)
}
} catch {
Write-Error "Error: could not set the active plan pointer: $($_.Exception.Message)"
exit 1
} finally {
if ($createdTemp -and (Test-Path -LiteralPath $tempFile)) {
Remove-Item -LiteralPath $tempFile -Force -ErrorAction SilentlyContinue
}
}
Write-Output "Active plan set to: $PlanId"
Write-Output "Path: $PlanDir"
Write-Output ""
+323 -38
View File
@@ -1,50 +1,335 @@
#!/bin/sh
# planning-with-files: set or display the active plan pointer.
#
# Usage:
# set-active-plan.sh <plan_id> — pin .planning/.active_plan to plan_id
# set-active-plan.sh — print the current active plan (if any)
#
# The active plan is stored in .planning/.active_plan and is read by
# resolve-plan-dir.sh when no $PLAN_ID env var is set.
set -e
# List saved named plans, show the shared pointer, or change that pointer.
# Usage: set-active-plan.sh [--list|-l|PLAN_ID]
# Operates on the current project; listing never binds a host or injects data.
set -eu
PLAN_ROOT="${PWD}/.planning"
ACTIVE_FILE="${PLAN_ROOT}/.active_plan"
PWF_ROOT_PIN=""
# No args → show current active plan
if [ "${1:-}" = "" ]; then
if [ -f "${ACTIVE_FILE}" ]; then
plan_id="$(tr -d '\r\n' < "${ACTIVE_FILE}")"
if [ -n "${plan_id}" ] && [ -d "${PLAN_ROOT}/${plan_id}" ]; then
echo "Active plan: ${plan_id}"
echo "Path: ${PLAN_ROOT}/${plan_id}"
elif [ -n "${plan_id}" ]; then
echo "Active plan pointer: ${plan_id} (directory not found — stale pointer)"
else
echo "No active plan set."
fi
# Use the resolver canonicalization policy without running plan selection.
slug_is_valid() {
case "$1" in
'') return 1 ;;
*[!A-Za-z0-9._-]*) return 1 ;;
[A-Za-z0-9_]*) return 0 ;;
esac
return 1
}
# Pure-sh backslash-to-forward-slash normalizer; result lands in $NORM_OUT.
# Windows-native coreutils builds (e.g. C:\Program Files\coreutils on PATH
# ahead of Git's usr/bin) canonicalize MSYS-style /c/... input to C:\-style
# backslash output. The containment prefix match below is written with forward
# slashes, so without this normalization every canonical pair mismatches and
# resolution silently fails. On POSIX systems paths contain no backslash and
# this is the identity. A literal backslash in a Unix filename normalizes to
# "/" and at worst fails containment — the safe direction. No subshell, no
# fork: plain parameter expansion in a loop.
norm_slashes() {
NORM_OUT=""
_ns_rest="$1"
while :; do
case "${_ns_rest}" in
*\\*)
NORM_OUT="${NORM_OUT}${_ns_rest%%\\*}/"
_ns_rest="${_ns_rest#*\\}"
;;
*)
NORM_OUT="${NORM_OUT}${_ns_rest}"
break
;;
esac
done
}
# Return true when a candidate path names the Microsoft Store WindowsApps
# directory. Store app aliases are not stable interpreter binaries and may
# present as executable while refusing script execution. Matching is
# case-insensitive and works before or after Windows slash normalization.
is_windowsapps_path() {
norm_slashes "$1"
case "${NORM_OUT}" in
[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]|\
[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]/*|\
*/[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]|\
*/[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]/*) return 0 ;;
esac
return 1
}
# Select only an interpreter path the caller explicitly trusted.
# PWF_TRUSTED_PYTHON is preferred; PYTHON_BIN remains a compatibility alias.
# PATH discovery is intentionally forbidden because resolver hooks can run in
# repositories that control PATH. Windows-native absolute paths are converted
# with Git Bash's fixed system cygpath, never a PATH-selected shim.
trusted_python() {
for _tp_candidate in "${PWF_TRUSTED_PYTHON:-}" "${PYTHON_BIN:-}"; do
[ -n "${_tp_candidate}" ] || continue
case "${_tp_candidate}" in
\\\\*|//*) continue ;;
[A-Za-z]:[\\/]*)
is_windowsapps_path "${_tp_candidate}" && continue
_tp_cygpath="/usr/bin/cygpath.exe"
[ -f "${_tp_cygpath}" ] && [ -x "${_tp_cygpath}" ] || continue
_tp_candidate="$("${_tp_cygpath}" -u "${_tp_candidate}" 2>/dev/null)" \
|| continue
;;
/*) ;;
*) continue ;;
esac
is_windowsapps_path "${_tp_candidate}" && continue
[ -f "${_tp_candidate}" ] || continue
[ -x "${_tp_candidate}" ] || continue
printf "%s\n" "${_tp_candidate}"
return 0
done
return 1
}
# Portable path canonicalizer. realpath first (Linux, modern coreutils),
# then readlink -f (older GNU), then an explicitly trusted Python interpreter.
# Prints the canonical absolute path on success; prints nothing and returns 1
# on a full miss so containment fails closed. No Python spawn on the happy
# path: realpath/readlink cover Linux, WSL, Git-Bash, and modern macOS.
canonicalize() {
target="$1"
if command -v realpath >/dev/null 2>&1; then
out="$(realpath "${target}" 2>/dev/null)" && [ -n "${out}" ] && {
printf "%s\n" "${out}"; return 0; }
fi
if command -v readlink >/dev/null 2>&1; then
out="$(readlink -f "${target}" 2>/dev/null)" && [ -n "${out}" ] && {
printf "%s\n" "${out}"; return 0; }
fi
_canonical_python="$(trusted_python)" || _canonical_python=""
if [ -n "${_canonical_python}" ]; then
out="$("${_canonical_python}" -I -c "import os,sys;print(os.path.realpath(sys.argv[1]))" "${target}" 2>/dev/null)" \
&& [ -n "${out}" ] && { printf "%s\n" "${out}"; return 0; }
fi
return 1
}
# Containment guard (security A1.3): a resolved plan dir must canonicalize to a
# path under the project root (the CWD the script runs from). A symlink inside
# a valid slug dir pointing at /etc or outside the workspace would otherwise let
# the hooks hash and inject an arbitrary file. On any violation we return 1 so
# the caller treats the candidate as unresolved and falls back safely.
#
# The root canonicalizes via the relative token "." rather than the $PWD
# string. On some Windows/MSYS setups (8.3 short names, the /tmp mount alias)
# realpath("$PWD") and realpath(relative-candidate) resolve through different
# code paths and land on differently-spelled-but-equal targets, so the prefix
# match below fails and resolution silently goes dark. "." resolves through
# the same physical-cwd path candidates already use (same fix inject-plan.sh
# received earlier; the resolver kept the $PWD form until now). Both sides are
# backslash-normalized before comparison for Windows-native canonicalizers.
# The root is computed once per run: the newest-mtime scan calls this guard
# per plan dir, and each canonicalize costs a process spawn on Windows.
#
# With a PWF_PLAN_ROOT pin (issue #212) containment is checked against THAT
# root instead of the cwd: candidates arrive ${PWF_PLAN_ROOT}/-prefixed, so
# both sides canonicalize through the same path spelling. Unpinned keeps the
# relative "." root — byte-identical to the legacy check.
ROOT_REAL=""
ROOT_REAL_SET=0
is_within_root() {
candidate="$1"
if [ "${ROOT_REAL_SET}" = "0" ]; then
ROOT_REAL="$(canonicalize "${PWF_ROOT_PIN:-.}")" || ROOT_REAL=""
norm_slashes "${ROOT_REAL}"
ROOT_REAL="${NORM_OUT}"
ROOT_REAL_SET=1
fi
# Canonicalize the candidate through its cwd-RELATIVE form whenever it
# lives under ${PWD}. The candidate string is built from ${PWD} (an MSYS
# long-form spelling), while the root canonicalizes from "." (the process
# cwd, which a caller may have set with an 8.3 short-form string). A
# Windows-native realpath does not unify those spellings, so canonicalizing
# both sides from the same cwd base is the only spelling-stable comparison.
# The emitted result keeps the original absolute candidate — only the
# containment check uses the relative form.
# Pinned resolution skips the rewrite: candidate and root then share the
# ${PWF_PLAN_ROOT} spelling, so both canonicalize directly from it.
if [ -n "${PWF_ROOT_PIN}" ]; then
check_target="${candidate}"
else
echo "No active plan set."
case "${candidate}" in
"${PWD}"/*) check_target=".${candidate#"${PWD}"}" ;;
*) check_target="${candidate}" ;;
esac
fi
cand_real="$(canonicalize "${check_target}")" || cand_real=""
norm_slashes "${cand_real}"
cand_real="${NORM_OUT}"
if [ -z "${ROOT_REAL}" ] || [ -z "${cand_real}" ]; then
# Slug validation blocks textual traversal, but only successful
# canonicalization can rule out a symlink/junction escape.
return 1
fi
case "${cand_real}" in
"${ROOT_REAL}"|"${ROOT_REAL}"/*) return 0 ;;
*) return 1 ;;
esac
}
# Each phase contributes at most one status. An explicit Status line wins
# over an inline heading marker. Fenced examples and unrelated sections are
# not phases. Keep this parser aligned with the PowerShell helper.
phase_status() {
awk '
function finish() {
if (phase) {
status = primary != "" ? primary : inline_status
if (status == "complete") complete++
else if (status == "in_progress") in_progress++
else if (status == "pending") pending++
}
phase = 0; primary = ""; inline_status = ""
}
{
line = $0
sub(/\r$/, "", line)
trimmed = line
sub(/^ */, "", trimmed)
if (line ~ /^ ? ? ?```/ || line ~ /^ ? ? ?~~~/) {
marker = substr(trimmed, 1, 1)
run = 0
while (substr(trimmed, run + 1, 1) == marker) run++
if (fence == "") { fence = marker; fence_length = run }
else if (marker == fence && run >= fence_length && substr(trimmed, run + 1) ~ /^[ \t]*$/) fence = ""
next
}
if (fence != "") next
if (line ~ /^ ? ? ?###[ \t]+(Phase|Fase|المرحلة|阶段|階段)[ \t]+[0-9]+([^0-9A-Za-z_]|$)/) {
finish(); phase = 1; total++
if (match(line, /\[(complete|in_progress|pending)\]/))
inline_status = substr(line, RSTART + 1, RLENGTH - 2)
} else if (line ~ /^ ? ? ?(###|##|#)([ \t]|$)/) {
finish()
} else if (phase && primary == "" && line ~ /^ ? ? ?(-[ \t]+)?\*\*(Status:|Estado:|الحالة:|状态:|狀態:)\*\*[ \t]+(complete|in_progress|pending)([ \t]|$)/) {
sub(/^ ? ? ?(-[ \t]+)?\*\*(Status:|Estado:|الحالة:|状态:|狀態:)\*\*[ \t]+/, "", line)
sub(/[ \t].*$/, "", line)
primary = line
}
}
END {
finish()
printf "%d/%d complete, %d in_progress, %d pending", complete, total, in_progress, pending
}
' < "$1"
}
current_active() {
if [ -f "${ACTIVE_FILE}" ] && is_within_root "${ACTIVE_FILE}"; then
_current="$(tr '\r' '\n' < "${ACTIVE_FILE}")"
# Windows editors and older PowerShell defaults can leave a UTF-8 BOM.
# Treat it as an encoding marker, not part of the shared plan slug.
_utf8_bom="$(printf '\357\273\277')"
case "${_current}" in
"${_utf8_bom}"*) _current="${_current#"${_utf8_bom}"}" ;;
esac
slug_is_valid "${_current}" && printf '%s\n' "${_current}"
fi
return 0
}
list_plans() {
if [ ! -d "${PLAN_ROOT}" ]; then
printf '%s\n' 'No planning directory found.'
return 0
fi
if ! is_within_root "${PLAN_ROOT}"; then
printf '%s\n' 'Error: planning directory is outside the project or cannot be verified.' >&2
return 1
fi
_active="$(current_active)"
_found=0
printf '%s\n' 'Available plans:'
for _dir in "${PLAN_ROOT}"/*; do
[ -d "${_dir}" ] || continue
_id="${_dir##*/}"
slug_is_valid "${_id}" || continue
is_within_root "${_dir}" || continue
_plan_file="${_dir}/task_plan.md"
[ -f "${_plan_file}" ] && [ -r "${_plan_file}" ] || continue
is_within_root "${_plan_file}" || continue
_status="$(phase_status "${_plan_file}")" || continue
_marker=''
if [ "${_id}" = "${_active}" ]; then _marker=' [active]'; fi
printf '%s\n' "- ${_id}${_marker} - ${_status}"
_found=1
done
if [ "${_found}" -eq 0 ]; then
printf '%s\n' 'No named plans found.'
else
printf '%s\n' '[active] marks the shared default pointer. Set PLAN_ID to pin a session.'
fi
}
if [ "$#" -gt 1 ]; then
printf '%s\n' 'Error: list plans or set PLAN_ID in separate calls.' >&2
exit 1
fi
case "${1:-}" in
--list|-l) list_plans; exit $? ;;
--help|-h)
printf '%s\n' 'Usage: set-active-plan.sh [--list|PLAN_ID]' \
'Lists saved named plans in the current project without selecting a plan.'
exit 0 ;;
esac
if [ "${1:-}" = '' ]; then
if [ -d "${PLAN_ROOT}" ] && ! is_within_root "${PLAN_ROOT}"; then
printf '%s\n' 'Error: planning directory is outside the project or cannot be verified.' >&2
exit 1
fi
plan_id="$(current_active)"
if [ -n "${plan_id}" ] && [ -d "${PLAN_ROOT}/${plan_id}" ] && is_within_root "${PLAN_ROOT}/${plan_id}"; then
printf '%s\n' "Active plan: ${plan_id}" "Path: ${PLAN_ROOT}/${plan_id}"
elif [ -n "${plan_id}" ]; then
printf '%s\n' "Active plan pointer: ${plan_id} (directory not found or outside project - stale pointer)"
else
printf '%s\n' 'No active plan set.'
fi
exit 0
fi
PLAN_ID="$1"
PLAN_DIR="${PLAN_ROOT}/${PLAN_ID}"
if [ ! -d "${PLAN_DIR}" ]; then
echo "Error: plan directory not found: ${PLAN_DIR}" >&2
echo "Run: init-session.sh \"${PLAN_ID}\" to create it, or check .planning/ for available plans." >&2
if ! slug_is_valid "${PLAN_ID}"; then
printf '%s\n' 'Error: invalid plan ID. Use a named directory under .planning.' >&2
exit 1
fi
mkdir -p "${PLAN_ROOT}"
printf "%s\n" "${PLAN_ID}" > "${ACTIVE_FILE}"
echo "Active plan set to: ${PLAN_ID}"
echo "Path: ${PLAN_DIR}"
echo ""
echo "To pin this terminal session only:"
echo " export PLAN_ID=${PLAN_ID}"
PLAN_DIR="${PLAN_ROOT}/${PLAN_ID}"
if [ ! -d "${PLAN_DIR}" ]; then
printf '%s\n' "Error: plan directory not found: ${PLAN_DIR}" \
"Run: init-session.sh \"${PLAN_ID}\" to create it, or use --list to see available plans." >&2
exit 1
fi
if ! is_within_root "${PLAN_ROOT}" || ! is_within_root "${PLAN_DIR}"; then
printf '%s\n' 'Error: plan directory is outside the project or cannot be verified.' >&2
exit 1
fi
# A pre-existing pointer must itself be a contained regular file before writing.
if { [ -e "${ACTIVE_FILE}" ] || [ -L "${ACTIVE_FILE}" ]; } &&
{ [ -L "${ACTIVE_FILE}" ] || [ ! -f "${ACTIVE_FILE}" ] || ! is_within_root "${ACTIVE_FILE}"; }; then
printf '%s\n' 'Error: active plan pointer is not a safe file inside the project.' >&2
exit 1
fi
# Replace the pointer atomically instead of truncating a possible hardlink.
# mktemp creates a private, exclusive file beside the destination.
temp_file="$(mktemp "${PLAN_ROOT}/.active_plan.XXXXXX")" || {
printf '%s\n' 'Error: could not create the active plan pointer.' >&2
exit 1
}
trap 'rm -f "${temp_file}"' EXIT
trap 'exit 1' HUP INT TERM
printf '%s\n' "${PLAN_ID}" > "${temp_file}"
if ! mv -f "${temp_file}" "${ACTIVE_FILE}"; then
printf '%s\n' 'Error: could not replace the active plan pointer.' >&2
exit 1
fi
trap - EXIT HUP INT TERM
printf '%s\n' "Active plan set to: ${PLAN_ID}" "Path: ${PLAN_DIR}" '' \
'To pin this terminal session only:' " export PLAN_ID=${PLAN_ID}"
+32 -4
View File
@@ -58,6 +58,14 @@ SCRIPTS = [
"scripts/plan-doctor.sh",
]
# Standalone plan inventory and selection helpers are also useful in hosts
# whose adapters do not consume the shared script bundle. Keep this small
# surface explicit so adding it never replaces their host-specific behavior.
LISTING_SCRIPTS = [
"scripts/set-active-plan.sh",
"scripts/set-active-plan.ps1",
]
# Every hook-bearing SKILL.md dispatches through skill-hook.sh. Its sibling
# injector needs ledger-summary.sh and resolve-plan-dir.sh; Stop also needs
# gate-stop.sh and the shared check-complete.sh. Ship the complete dependency
@@ -225,17 +233,27 @@ IDE_MANIFESTS = {
".opencode/skills/planning-with-files",
ref_style="flat",
include_scripts=False,
extra_scripts=HOOK_DISPATCH_SCRIPTS,
extra_scripts=HOOK_DISPATCH_SCRIPTS + LISTING_SCRIPTS,
),
# Mastracode: templates/references maintained under .mastracode/; only the
# hook dispatch targets are synced from canonical.
# hook dispatch targets and standalone plan listing helpers are synced.
".mastracode": _build_manifest(
".mastracode/skills/planning-with-files",
ref_style="skip",
template_dirs=[],
include_scripts=False,
extra_scripts=HOOK_DISPATCH_SCRIPTS,
extra_scripts=HOOK_DISPATCH_SCRIPTS + LISTING_SCRIPTS,
),
# Hermes owns its Python lifecycle adapter and its skill bundle. Only
# this standalone helper pair is added to its canonical sync inventory.
".hermes": _build_manifest(
".hermes/skills/planning-with-files",
ref_style="skip",
template_dirs=[],
include_scripts=False,
extra_scripts=LISTING_SCRIPTS,
),
# Kiro: maintained under .kiro/ (skill + wrappers); not synced from canonical scripts/.
@@ -320,6 +338,7 @@ IDE_MANIFESTS["."] = {
"scripts/inject-plan.py": "scripts/inject-plan.py",
"scripts/skill-hook.sh": "scripts/skill-hook.sh",
"templates/loop.md": "templates/loop.md",
**{script: script for script in LISTING_SCRIPTS},
}
@@ -414,7 +433,11 @@ def main(argv=None):
# Verify mode: just check for drift
src_hash = file_hash(src)
dst_hash = file_hash(dst)
if src_hash and dst_hash and src_hash != dst_hash:
if src_hash is None:
print(f" MISSING SOURCE: {src}")
stats["missing_src"] += 1
ide_changes += 1
elif src_hash and dst_hash and src_hash != dst_hash:
print(f" DRIFT: {dst}")
stats["drift"] += 1
ide_changes += 1
@@ -435,6 +458,11 @@ def main(argv=None):
# Summary
print(f"\n{'-' * 50}")
if verify:
if stats["missing_src"] > 0:
print(f"MISSING SOURCES: {stats['missing_src']} sync entry/entries "
"have no canonical source.")
print("Restore the missing canonical files, then rerun --verify.")
sys.exit(1)
total_drift = stats["drift"]
if total_drift > 0:
print(f"DRIFT DETECTED: {total_drift} file(s) out of sync.")
+7 -1
View File
@@ -36,7 +36,7 @@ hooks:
- type: command
command: "SH=\"\"; for c in \"${PWF_SCRIPT_DIR}/skill-hook.sh\" \"${CLAUDE_SKILL_DIR}/scripts/skill-hook.sh\" \"$HOME/.claude/skills/planning-with-files-ar/scripts/skill-hook.sh\" \"$HOME/.claude/skills/planning-with-files/scripts/skill-hook.sh\" \"$HOME/.claude/plugins/marketplaces/planning-with-files/scripts/skill-hook.sh\"; do [ -f \"$c\" ] && { SH=\"$c\"; break; }; done; [ -n \"$SH\" ] && sh \"$SH\" --event=precompact; exit 0"
metadata:
version: "3.17.2"
version: "3.18.2"
---
# نظام تخطيط الملفات
@@ -231,6 +231,12 @@ if فشل العملية:
- `scripts/check-complete.sh` — التحقق من اكتمال جميع المراحل
- `scripts/session-catchup.py`: فحص صريح لبيانات الجلسة المحلية أو إعادة تشغيل محدودة منها
### عرض الخطط المحفوظة
للعثور على مهمة قبل استئنافها، شغّل `sh "<skill-dir>/scripts/set-active-plan.sh" --list` أو، في Windows PowerShell، `& "<skill-dir>/scripts/set-active-plan.ps1" -List`. استبدل `<skill-dir>` بمسار تثبيت هذه المهارة، وأبقِ دليل العمل الحالي عند جذر المشروع.
يعرض هذا الأمر للقراءة فقط الخطط المسماة وتقدّم مراحلها داخل `.planning/` في دليل العمل الحالي. تشير `[active]` إلى المؤشر الافتراضي المشترك، ولا تربط جلسة بخطة. تتطلب المهام المتزامنة تعيين `PLAN_ID` لكل مضيف أو استخدام أشجار عمل منفصلة.
## الحدود الأمنية
تستخدم هذه المهارة خطاف PreToolUse لإعادة قراءة `task_plan.md` قبل كل استدعاء أداة. المحتوى المكتوب في `task_plan.md` يُحقن بشكل متكرر في السياق، مما يجعله هدفًا ذا قيمة عالية للحقن غير المباشر عبر المطالبات.
+266 -16
View File
@@ -1,27 +1,245 @@
# planning-with-files: set or display the active plan pointer (PowerShell).
# planning-with-files: set, display, or list the active plan pointer (PowerShell).
#
# Usage:
# .\set-active-plan.ps1 <plan_id> - pin .planning\.active_plan to plan_id
# .\set-active-plan.ps1 - print the current active plan (if any)
# .\set-active-plan.ps1 -List - list available named plans and phase counts
# .\set-active-plan.ps1 --list - equivalent to -List and -l
param(
[string]$PlanId = ""
[Parameter(Position = 0)]
[string]$PlanId = "",
[Alias('l', '-list')]
[switch]$List,
[Alias('h', '-help')]
[switch]$Help
)
$PlanRoot = Join-Path (Get-Location) ".planning"
$ProjectRoot = (Get-Location).Path
# Windows PowerShell 5.1 can silently change directories when -File inherits
# a cwd containing wildcard characters. Recover that physical cwd only for a
# direct invocation of this script; interactive Set-Location remains primary.
if ($PSVersionTable.PSVersion.Major -eq 5 -and
[System.Management.Automation.WildcardPattern]::ContainsWildcardCharacters([Environment]::CurrentDirectory)) {
$processArgs = [Environment]::GetCommandLineArgs()
for ($index = 0; $index -lt ($processArgs.Length - 1); $index++) {
if ($processArgs[$index] -ieq '-File') {
$entryScript = $processArgs[$index + 1]
if (-not [IO.Path]::IsPathRooted($entryScript)) {
$entryScript = Join-Path ([Environment]::CurrentDirectory) $entryScript
}
if ([IO.Path]::GetFullPath($entryScript) -eq [IO.Path]::GetFullPath($PSCommandPath)) {
$ProjectRoot = [Environment]::CurrentDirectory
}
break
}
}
}
$PlanRoot = Join-Path $ProjectRoot ".planning"
$ActiveFile = Join-Path $PlanRoot ".active_plan"
$script:IsWindowsHost = [Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT
# Resolve-Path can retain a junction's spelling. Like resolve-plan-dir.ps1,
# open the actual filesystem object before checking the project boundary.
# The Windows handle supports both directories and task_plan.md files.
function Get-FinalPath {
param([string]$Path)
if (-not ("PwfPlanListingNative" -as [type])) {
Add-Type -ErrorAction Stop -TypeDefinition @'
using System;
using System.ComponentModel;
using System.Runtime.InteropServices;
using System.Text;
using Microsoft.Win32.SafeHandles;
public static class PwfPlanListingNative {
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern SafeFileHandle CreateFileW(
string name, uint access, uint share, IntPtr security,
uint creation, uint flags, IntPtr template);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern uint GetFinalPathNameByHandleW(
SafeFileHandle handle, StringBuilder path, uint length, uint flags);
[DllImport("libc", SetLastError = true)]
private static extern IntPtr realpath(string path, IntPtr resolved);
[DllImport("libc")]
private static extern void free(IntPtr pointer);
public static string FinalPath(string path) {
if (Environment.OSVersion.Platform != PlatformID.Win32NT) {
IntPtr resolved = realpath(path, IntPtr.Zero);
if (resolved == IntPtr.Zero) throw new Win32Exception(Marshal.GetLastWin32Error());
try { return Marshal.PtrToStringAnsi(resolved); }
finally { free(resolved); }
}
// OPEN_EXISTING, FILE_SHARE_READ | WRITE | DELETE, BACKUP_SEMANTICS.
using (SafeFileHandle handle = CreateFileW(
path, 0, 7, IntPtr.Zero, 3, 0x02000000, IntPtr.Zero)) {
if (handle.IsInvalid) throw new Win32Exception(Marshal.GetLastWin32Error());
StringBuilder buffer = new StringBuilder(32768);
uint length = GetFinalPathNameByHandleW(handle, buffer, (uint)buffer.Capacity, 0);
if (length == 0 || length >= buffer.Capacity)
throw new Win32Exception(Marshal.GetLastWin32Error());
string result = buffer.ToString();
if (result.StartsWith(@"\\?\UNC\", StringComparison.OrdinalIgnoreCase))
return @"\\" + result.Substring(8);
if (result.StartsWith(@"\\?\", StringComparison.OrdinalIgnoreCase))
return result.Substring(4);
return result;
}
}
}
'@
}
return [PwfPlanListingNative]::FinalPath($Path)
}
function Test-WithinRoot {
param([string]$Path)
try {
$rootReal = (Get-FinalPath $ProjectRoot).TrimEnd('\', '/')
$pathReal = (Get-FinalPath $Path).TrimEnd('\', '/')
$comparison = [StringComparison]::Ordinal
if ($script:IsWindowsHost) { $comparison = [StringComparison]::OrdinalIgnoreCase }
return $pathReal.Equals($rootReal, $comparison) -or
$pathReal.StartsWith($rootReal + [IO.Path]::DirectorySeparatorChar, $comparison)
} catch {
return $false
}
}
function Test-ValidSlug {
param([string]$Name)
return $Name -cmatch '^[A-Za-z0-9_][A-Za-z0-9._-]*\z'
}
function Test-SafeActiveFile {
param([switch]$AllowLink)
$item = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
if (-not $item) { return $false }
# Reading may follow a verified in-project link; writing must not.
return -not $item.PSIsContainer -and
($AllowLink -or (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0)) -and
(Test-WithinRoot $ActiveFile)
}
function Get-CurrentActivePlan {
if (-not (Test-SafeActiveFile -AllowLink)) { return "" }
try {
$current = ([string](Get-Content -LiteralPath $ActiveFile -Raw -Encoding UTF8 -ErrorAction Stop)).Trim([char[]]"`r`n")
if (Test-ValidSlug $current) { return $current }
} catch { }
return ""
}
function Add-PhaseStatus {
param([hashtable]$Counts, $Phase)
if ($null -eq $Phase) { return }
$status = $Phase.Inline
if ($Phase.Primary) { $status = $Phase.Primary }
if ($status) { $Counts[$status]++ }
}
function Get-PhaseStatus {
param([string]$PlanFile)
$counts = @{ total = 0; complete = 0; in_progress = 0; pending = 0 }
$phase = $null
$fence = ""
$fenceLength = 0
# The shipped translations keep status identifiers in English. Unicode
# escapes preserve Windows PowerShell 5.1 compatibility without a BOM.
$phaseLabel = '(?:Phase|Fase|\u0627\u0644\u0645\u0631\u062d\u0644\u0629|\u9636\u6bb5|\u968e\u6bb5)'
$statusLabel = '(?:Status:|Estado:|\u0627\u0644\u062d\u0627\u0644\u0629:|\u72b6\u6001\uff1a|\u72c0\u614b\uff1a)'
# Read once; an empty file is a valid plan with zero phases.
$content = [string](Get-Content -LiteralPath $PlanFile -Raw -Encoding UTF8 -ErrorAction Stop)
foreach ($line in ($content -split '\r?\n')) {
if ($fence) {
$closing = '^ {0,3}' + [regex]::Escape($fence) + '{' + $fenceLength + ',}[ \t]*$'
if ($line -match $closing) { $fence = "" }
continue
}
if ($line -match '^ {0,3}(`{3,}|~{3,})') {
$fence = $Matches[1].Substring(0, 1)
$fenceLength = $Matches[1].Length
continue
}
if ($line -cmatch ('^ {0,3}###[ \t]+' + $phaseLabel + '[ \t]+[0-9]+(?:[^0-9A-Za-z_]|$)')) {
Add-PhaseStatus $counts $phase
$phase = @{ Primary = ""; Inline = "" }
$counts.total++
if ($line -cmatch '\[(complete|in_progress|pending)\]') {
$phase.Inline = $Matches[1]
}
continue
}
if ($line -match '^ {0,3}#{1,3}(?:[ \t]+|$)') {
Add-PhaseStatus $counts $phase
$phase = $null
continue
}
if ($null -ne $phase -and -not $phase.Primary -and
$line -cmatch ('^ {0,3}(?:-[ \t]+)?\*\*' + $statusLabel + '\*\*[ \t]+(complete|in_progress|pending)(?:[ \t]|$)')) {
$phase.Primary = $Matches[1]
}
}
Add-PhaseStatus $counts $phase
return "$($counts.complete)/$($counts.total) complete, $($counts.in_progress) in_progress, $($counts.pending) pending"
}
function Show-PlanList {
if (-not (Test-Path -LiteralPath $PlanRoot -PathType Container)) {
Write-Output "No planning directory found."
return
}
if (-not (Test-WithinRoot $PlanRoot)) {
Write-Error "Error: planning directory is outside the project or cannot be verified."
exit 1
}
$active = Get-CurrentActivePlan
Write-Output "Available plans:"
Write-Output "[active] marks the shared .active_plan pointer; listing does not bind this session."
$found = $false
foreach ($plan in (Get-ChildItem -LiteralPath $PlanRoot -Directory -ErrorAction SilentlyContinue | Sort-Object Name)) {
if (-not (Test-ValidSlug $plan.Name) -or -not (Test-WithinRoot $plan.FullName)) { continue }
$planFile = Join-Path $plan.FullName "task_plan.md"
if (-not (Test-Path -LiteralPath $planFile -PathType Leaf) -or -not (Test-WithinRoot $planFile)) { continue }
try { $status = Get-PhaseStatus $planFile } catch { continue }
$marker = ""
if ($plan.Name -ceq $active) { $marker = " [active]" }
Write-Output "- $($plan.Name)$marker - $status"
$found = $true
}
if (-not $found) { Write-Output "No named plans found." }
}
if ($Help -or $PlanId -eq "--help" -or $PlanId -eq "-h") {
Write-Output "Usage: set-active-plan.ps1 [-List|-l|--list|PLAN_ID]"
exit 0
}
if ($List -or $PlanId -eq "--list" -or $PlanId -eq "-l") {
if ($List -and $PlanId) {
Write-Error "Error: list plans or set PLAN_ID in separate calls."
exit 1
}
Show-PlanList
exit 0
}
if ($PlanId -eq "") {
if (Test-Path $ActiveFile) {
$current = (Get-Content $ActiveFile -Raw -Encoding UTF8).Trim()
$current = ""
if ((Test-Path -LiteralPath $PlanRoot -PathType Container) -and -not (Test-WithinRoot $PlanRoot)) {
Write-Error "Error: planning directory is outside the project or cannot be verified."
exit 1
}
if (Test-WithinRoot $PlanRoot) { $current = Get-CurrentActivePlan }
if ($current) {
$planDir = Join-Path $PlanRoot $current
if ($current -ne "" -and (Test-Path $planDir)) {
if ((Test-Path -LiteralPath $planDir -PathType Container) -and (Test-WithinRoot $planDir)) {
Write-Output "Active plan: $current"
Write-Output "Path: $planDir"
} elseif ($current -ne "") {
Write-Output "Active plan pointer: $current (directory not found - stale pointer)"
} else {
Write-Output "No active plan set."
Write-Output "Active plan pointer: $current (directory not found or outside project - stale pointer)"
}
} else {
Write-Output "No active plan set."
@@ -29,21 +247,53 @@ if ($PlanId -eq "") {
exit 0
}
if (-not (Test-ValidSlug $PlanId)) {
Write-Error "Error: invalid plan ID. Use letters, numbers, underscores, dots, or hyphens; start with a letter, number, or underscore."
exit 1
}
$PlanDir = Join-Path $PlanRoot $PlanId
if (-not (Test-Path $PlanDir)) {
if (-not (Test-Path -LiteralPath $PlanDir -PathType Container)) {
Write-Error "Error: plan directory not found: $PlanDir"
Write-Error "Run: init-session.sh `"$PlanId`" to create it, or check .planning\ for available plans."
exit 1
}
if (-not (Test-Path $PlanRoot)) {
New-Item -ItemType Directory -Path $PlanRoot -Force | Out-Null
if (-not (Test-WithinRoot $PlanRoot) -or -not (Test-WithinRoot $PlanDir)) {
Write-Error "Error: plan directory must remain within the project."
exit 1
}
$activeItem = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
if ($activeItem -and -not (Test-SafeActiveFile)) {
Write-Error "Error: the active plan pointer must be a regular file within the project."
exit 1
}
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($ActiveFile, $PlanId, $utf8NoBom)
# Replace the directory entry instead of truncating an existing inode: a
# hardlinked pointer must not overwrite another file. The exclusive temporary
# file lives beside the pointer, keeping replacement on the same filesystem.
$tempFile = Join-Path $PlanRoot ('.active_plan.' + [guid]::NewGuid().ToString('N') + '.tmp')
$createdTemp = $false
try {
$stream = [IO.File]::Open($tempFile, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
$createdTemp = $true
try {
$bytes = $utf8NoBom.GetBytes($PlanId)
$stream.Write($bytes, 0, $bytes.Length)
$stream.Flush()
} finally { $stream.Dispose() }
if ($activeItem) {
[IO.File]::Replace($tempFile, $ActiveFile, [NullString]::Value)
} else {
[IO.File]::Move($tempFile, $ActiveFile)
}
} catch {
Write-Error "Error: could not set the active plan pointer: $($_.Exception.Message)"
exit 1
} finally {
if ($createdTemp -and (Test-Path -LiteralPath $tempFile)) {
Remove-Item -LiteralPath $tempFile -Force -ErrorAction SilentlyContinue
}
}
Write-Output "Active plan set to: $PlanId"
Write-Output "Path: $PlanDir"
Write-Output ""
+323 -38
View File
@@ -1,50 +1,335 @@
#!/bin/sh
# planning-with-files: set or display the active plan pointer.
#
# Usage:
# set-active-plan.sh <plan_id> — pin .planning/.active_plan to plan_id
# set-active-plan.sh — print the current active plan (if any)
#
# The active plan is stored in .planning/.active_plan and is read by
# resolve-plan-dir.sh when no $PLAN_ID env var is set.
set -e
# List saved named plans, show the shared pointer, or change that pointer.
# Usage: set-active-plan.sh [--list|-l|PLAN_ID]
# Operates on the current project; listing never binds a host or injects data.
set -eu
PLAN_ROOT="${PWD}/.planning"
ACTIVE_FILE="${PLAN_ROOT}/.active_plan"
PWF_ROOT_PIN=""
# No args → show current active plan
if [ "${1:-}" = "" ]; then
if [ -f "${ACTIVE_FILE}" ]; then
plan_id="$(tr -d '\r\n' < "${ACTIVE_FILE}")"
if [ -n "${plan_id}" ] && [ -d "${PLAN_ROOT}/${plan_id}" ]; then
echo "Active plan: ${plan_id}"
echo "Path: ${PLAN_ROOT}/${plan_id}"
elif [ -n "${plan_id}" ]; then
echo "Active plan pointer: ${plan_id} (directory not found — stale pointer)"
else
echo "No active plan set."
fi
# Use the resolver canonicalization policy without running plan selection.
slug_is_valid() {
case "$1" in
'') return 1 ;;
*[!A-Za-z0-9._-]*) return 1 ;;
[A-Za-z0-9_]*) return 0 ;;
esac
return 1
}
# Pure-sh backslash-to-forward-slash normalizer; result lands in $NORM_OUT.
# Windows-native coreutils builds (e.g. C:\Program Files\coreutils on PATH
# ahead of Git's usr/bin) canonicalize MSYS-style /c/... input to C:\-style
# backslash output. The containment prefix match below is written with forward
# slashes, so without this normalization every canonical pair mismatches and
# resolution silently fails. On POSIX systems paths contain no backslash and
# this is the identity. A literal backslash in a Unix filename normalizes to
# "/" and at worst fails containment — the safe direction. No subshell, no
# fork: plain parameter expansion in a loop.
norm_slashes() {
NORM_OUT=""
_ns_rest="$1"
while :; do
case "${_ns_rest}" in
*\\*)
NORM_OUT="${NORM_OUT}${_ns_rest%%\\*}/"
_ns_rest="${_ns_rest#*\\}"
;;
*)
NORM_OUT="${NORM_OUT}${_ns_rest}"
break
;;
esac
done
}
# Return true when a candidate path names the Microsoft Store WindowsApps
# directory. Store app aliases are not stable interpreter binaries and may
# present as executable while refusing script execution. Matching is
# case-insensitive and works before or after Windows slash normalization.
is_windowsapps_path() {
norm_slashes "$1"
case "${NORM_OUT}" in
[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]|\
[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]/*|\
*/[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]|\
*/[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]/*) return 0 ;;
esac
return 1
}
# Select only an interpreter path the caller explicitly trusted.
# PWF_TRUSTED_PYTHON is preferred; PYTHON_BIN remains a compatibility alias.
# PATH discovery is intentionally forbidden because resolver hooks can run in
# repositories that control PATH. Windows-native absolute paths are converted
# with Git Bash's fixed system cygpath, never a PATH-selected shim.
trusted_python() {
for _tp_candidate in "${PWF_TRUSTED_PYTHON:-}" "${PYTHON_BIN:-}"; do
[ -n "${_tp_candidate}" ] || continue
case "${_tp_candidate}" in
\\\\*|//*) continue ;;
[A-Za-z]:[\\/]*)
is_windowsapps_path "${_tp_candidate}" && continue
_tp_cygpath="/usr/bin/cygpath.exe"
[ -f "${_tp_cygpath}" ] && [ -x "${_tp_cygpath}" ] || continue
_tp_candidate="$("${_tp_cygpath}" -u "${_tp_candidate}" 2>/dev/null)" \
|| continue
;;
/*) ;;
*) continue ;;
esac
is_windowsapps_path "${_tp_candidate}" && continue
[ -f "${_tp_candidate}" ] || continue
[ -x "${_tp_candidate}" ] || continue
printf "%s\n" "${_tp_candidate}"
return 0
done
return 1
}
# Portable path canonicalizer. realpath first (Linux, modern coreutils),
# then readlink -f (older GNU), then an explicitly trusted Python interpreter.
# Prints the canonical absolute path on success; prints nothing and returns 1
# on a full miss so containment fails closed. No Python spawn on the happy
# path: realpath/readlink cover Linux, WSL, Git-Bash, and modern macOS.
canonicalize() {
target="$1"
if command -v realpath >/dev/null 2>&1; then
out="$(realpath "${target}" 2>/dev/null)" && [ -n "${out}" ] && {
printf "%s\n" "${out}"; return 0; }
fi
if command -v readlink >/dev/null 2>&1; then
out="$(readlink -f "${target}" 2>/dev/null)" && [ -n "${out}" ] && {
printf "%s\n" "${out}"; return 0; }
fi
_canonical_python="$(trusted_python)" || _canonical_python=""
if [ -n "${_canonical_python}" ]; then
out="$("${_canonical_python}" -I -c "import os,sys;print(os.path.realpath(sys.argv[1]))" "${target}" 2>/dev/null)" \
&& [ -n "${out}" ] && { printf "%s\n" "${out}"; return 0; }
fi
return 1
}
# Containment guard (security A1.3): a resolved plan dir must canonicalize to a
# path under the project root (the CWD the script runs from). A symlink inside
# a valid slug dir pointing at /etc or outside the workspace would otherwise let
# the hooks hash and inject an arbitrary file. On any violation we return 1 so
# the caller treats the candidate as unresolved and falls back safely.
#
# The root canonicalizes via the relative token "." rather than the $PWD
# string. On some Windows/MSYS setups (8.3 short names, the /tmp mount alias)
# realpath("$PWD") and realpath(relative-candidate) resolve through different
# code paths and land on differently-spelled-but-equal targets, so the prefix
# match below fails and resolution silently goes dark. "." resolves through
# the same physical-cwd path candidates already use (same fix inject-plan.sh
# received earlier; the resolver kept the $PWD form until now). Both sides are
# backslash-normalized before comparison for Windows-native canonicalizers.
# The root is computed once per run: the newest-mtime scan calls this guard
# per plan dir, and each canonicalize costs a process spawn on Windows.
#
# With a PWF_PLAN_ROOT pin (issue #212) containment is checked against THAT
# root instead of the cwd: candidates arrive ${PWF_PLAN_ROOT}/-prefixed, so
# both sides canonicalize through the same path spelling. Unpinned keeps the
# relative "." root — byte-identical to the legacy check.
ROOT_REAL=""
ROOT_REAL_SET=0
is_within_root() {
candidate="$1"
if [ "${ROOT_REAL_SET}" = "0" ]; then
ROOT_REAL="$(canonicalize "${PWF_ROOT_PIN:-.}")" || ROOT_REAL=""
norm_slashes "${ROOT_REAL}"
ROOT_REAL="${NORM_OUT}"
ROOT_REAL_SET=1
fi
# Canonicalize the candidate through its cwd-RELATIVE form whenever it
# lives under ${PWD}. The candidate string is built from ${PWD} (an MSYS
# long-form spelling), while the root canonicalizes from "." (the process
# cwd, which a caller may have set with an 8.3 short-form string). A
# Windows-native realpath does not unify those spellings, so canonicalizing
# both sides from the same cwd base is the only spelling-stable comparison.
# The emitted result keeps the original absolute candidate — only the
# containment check uses the relative form.
# Pinned resolution skips the rewrite: candidate and root then share the
# ${PWF_PLAN_ROOT} spelling, so both canonicalize directly from it.
if [ -n "${PWF_ROOT_PIN}" ]; then
check_target="${candidate}"
else
echo "No active plan set."
case "${candidate}" in
"${PWD}"/*) check_target=".${candidate#"${PWD}"}" ;;
*) check_target="${candidate}" ;;
esac
fi
cand_real="$(canonicalize "${check_target}")" || cand_real=""
norm_slashes "${cand_real}"
cand_real="${NORM_OUT}"
if [ -z "${ROOT_REAL}" ] || [ -z "${cand_real}" ]; then
# Slug validation blocks textual traversal, but only successful
# canonicalization can rule out a symlink/junction escape.
return 1
fi
case "${cand_real}" in
"${ROOT_REAL}"|"${ROOT_REAL}"/*) return 0 ;;
*) return 1 ;;
esac
}
# Each phase contributes at most one status. An explicit Status line wins
# over an inline heading marker. Fenced examples and unrelated sections are
# not phases. Keep this parser aligned with the PowerShell helper.
phase_status() {
awk '
function finish() {
if (phase) {
status = primary != "" ? primary : inline_status
if (status == "complete") complete++
else if (status == "in_progress") in_progress++
else if (status == "pending") pending++
}
phase = 0; primary = ""; inline_status = ""
}
{
line = $0
sub(/\r$/, "", line)
trimmed = line
sub(/^ */, "", trimmed)
if (line ~ /^ ? ? ?```/ || line ~ /^ ? ? ?~~~/) {
marker = substr(trimmed, 1, 1)
run = 0
while (substr(trimmed, run + 1, 1) == marker) run++
if (fence == "") { fence = marker; fence_length = run }
else if (marker == fence && run >= fence_length && substr(trimmed, run + 1) ~ /^[ \t]*$/) fence = ""
next
}
if (fence != "") next
if (line ~ /^ ? ? ?###[ \t]+(Phase|Fase|المرحلة|阶段|階段)[ \t]+[0-9]+([^0-9A-Za-z_]|$)/) {
finish(); phase = 1; total++
if (match(line, /\[(complete|in_progress|pending)\]/))
inline_status = substr(line, RSTART + 1, RLENGTH - 2)
} else if (line ~ /^ ? ? ?(###|##|#)([ \t]|$)/) {
finish()
} else if (phase && primary == "" && line ~ /^ ? ? ?(-[ \t]+)?\*\*(Status:|Estado:|الحالة:|状态:|狀態:)\*\*[ \t]+(complete|in_progress|pending)([ \t]|$)/) {
sub(/^ ? ? ?(-[ \t]+)?\*\*(Status:|Estado:|الحالة:|状态:|狀態:)\*\*[ \t]+/, "", line)
sub(/[ \t].*$/, "", line)
primary = line
}
}
END {
finish()
printf "%d/%d complete, %d in_progress, %d pending", complete, total, in_progress, pending
}
' < "$1"
}
current_active() {
if [ -f "${ACTIVE_FILE}" ] && is_within_root "${ACTIVE_FILE}"; then
_current="$(tr '\r' '\n' < "${ACTIVE_FILE}")"
# Windows editors and older PowerShell defaults can leave a UTF-8 BOM.
# Treat it as an encoding marker, not part of the shared plan slug.
_utf8_bom="$(printf '\357\273\277')"
case "${_current}" in
"${_utf8_bom}"*) _current="${_current#"${_utf8_bom}"}" ;;
esac
slug_is_valid "${_current}" && printf '%s\n' "${_current}"
fi
return 0
}
list_plans() {
if [ ! -d "${PLAN_ROOT}" ]; then
printf '%s\n' 'No planning directory found.'
return 0
fi
if ! is_within_root "${PLAN_ROOT}"; then
printf '%s\n' 'Error: planning directory is outside the project or cannot be verified.' >&2
return 1
fi
_active="$(current_active)"
_found=0
printf '%s\n' 'Available plans:'
for _dir in "${PLAN_ROOT}"/*; do
[ -d "${_dir}" ] || continue
_id="${_dir##*/}"
slug_is_valid "${_id}" || continue
is_within_root "${_dir}" || continue
_plan_file="${_dir}/task_plan.md"
[ -f "${_plan_file}" ] && [ -r "${_plan_file}" ] || continue
is_within_root "${_plan_file}" || continue
_status="$(phase_status "${_plan_file}")" || continue
_marker=''
if [ "${_id}" = "${_active}" ]; then _marker=' [active]'; fi
printf '%s\n' "- ${_id}${_marker} - ${_status}"
_found=1
done
if [ "${_found}" -eq 0 ]; then
printf '%s\n' 'No named plans found.'
else
printf '%s\n' '[active] marks the shared default pointer. Set PLAN_ID to pin a session.'
fi
}
if [ "$#" -gt 1 ]; then
printf '%s\n' 'Error: list plans or set PLAN_ID in separate calls.' >&2
exit 1
fi
case "${1:-}" in
--list|-l) list_plans; exit $? ;;
--help|-h)
printf '%s\n' 'Usage: set-active-plan.sh [--list|PLAN_ID]' \
'Lists saved named plans in the current project without selecting a plan.'
exit 0 ;;
esac
if [ "${1:-}" = '' ]; then
if [ -d "${PLAN_ROOT}" ] && ! is_within_root "${PLAN_ROOT}"; then
printf '%s\n' 'Error: planning directory is outside the project or cannot be verified.' >&2
exit 1
fi
plan_id="$(current_active)"
if [ -n "${plan_id}" ] && [ -d "${PLAN_ROOT}/${plan_id}" ] && is_within_root "${PLAN_ROOT}/${plan_id}"; then
printf '%s\n' "Active plan: ${plan_id}" "Path: ${PLAN_ROOT}/${plan_id}"
elif [ -n "${plan_id}" ]; then
printf '%s\n' "Active plan pointer: ${plan_id} (directory not found or outside project - stale pointer)"
else
printf '%s\n' 'No active plan set.'
fi
exit 0
fi
PLAN_ID="$1"
PLAN_DIR="${PLAN_ROOT}/${PLAN_ID}"
if [ ! -d "${PLAN_DIR}" ]; then
echo "Error: plan directory not found: ${PLAN_DIR}" >&2
echo "Run: init-session.sh \"${PLAN_ID}\" to create it, or check .planning/ for available plans." >&2
if ! slug_is_valid "${PLAN_ID}"; then
printf '%s\n' 'Error: invalid plan ID. Use a named directory under .planning.' >&2
exit 1
fi
mkdir -p "${PLAN_ROOT}"
printf "%s\n" "${PLAN_ID}" > "${ACTIVE_FILE}"
echo "Active plan set to: ${PLAN_ID}"
echo "Path: ${PLAN_DIR}"
echo ""
echo "To pin this terminal session only:"
echo " export PLAN_ID=${PLAN_ID}"
PLAN_DIR="${PLAN_ROOT}/${PLAN_ID}"
if [ ! -d "${PLAN_DIR}" ]; then
printf '%s\n' "Error: plan directory not found: ${PLAN_DIR}" \
"Run: init-session.sh \"${PLAN_ID}\" to create it, or use --list to see available plans." >&2
exit 1
fi
if ! is_within_root "${PLAN_ROOT}" || ! is_within_root "${PLAN_DIR}"; then
printf '%s\n' 'Error: plan directory is outside the project or cannot be verified.' >&2
exit 1
fi
# A pre-existing pointer must itself be a contained regular file before writing.
if { [ -e "${ACTIVE_FILE}" ] || [ -L "${ACTIVE_FILE}" ]; } &&
{ [ -L "${ACTIVE_FILE}" ] || [ ! -f "${ACTIVE_FILE}" ] || ! is_within_root "${ACTIVE_FILE}"; }; then
printf '%s\n' 'Error: active plan pointer is not a safe file inside the project.' >&2
exit 1
fi
# Replace the pointer atomically instead of truncating a possible hardlink.
# mktemp creates a private, exclusive file beside the destination.
temp_file="$(mktemp "${PLAN_ROOT}/.active_plan.XXXXXX")" || {
printf '%s\n' 'Error: could not create the active plan pointer.' >&2
exit 1
}
trap 'rm -f "${temp_file}"' EXIT
trap 'exit 1' HUP INT TERM
printf '%s\n' "${PLAN_ID}" > "${temp_file}"
if ! mv -f "${temp_file}" "${ACTIVE_FILE}"; then
printf '%s\n' 'Error: could not replace the active plan pointer.' >&2
exit 1
fi
trap - EXIT HUP INT TERM
printf '%s\n' "Active plan set to: ${PLAN_ID}" "Path: ${PLAN_DIR}" '' \
'To pin this terminal session only:' " export PLAN_ID=${PLAN_ID}"
+7 -1
View File
@@ -36,7 +36,7 @@ hooks:
- type: command
command: "SH=\"\"; for c in \"${PWF_SCRIPT_DIR}/skill-hook.sh\" \"${CLAUDE_SKILL_DIR}/scripts/skill-hook.sh\" \"$HOME/.claude/skills/planning-with-files-de/scripts/skill-hook.sh\" \"$HOME/.claude/skills/planning-with-files/scripts/skill-hook.sh\" \"$HOME/.claude/plugins/marketplaces/planning-with-files/scripts/skill-hook.sh\"; do [ -f \"$c\" ] && { SH=\"$c\"; break; }; done; [ -n \"$SH\" ] && sh \"$SH\" --event=precompact; exit 0"
metadata:
version: "3.17.2"
version: "3.18.2"
---
# Dateiplanungssystem
@@ -230,6 +230,12 @@ Automatisierungshilfsskripte:
- `scripts/check-complete.sh` — Prüfen, ob alle Phasen abgeschlossen sind
- `scripts/session-catchup.py`: Auf ausdrückliche Anforderung Metadaten oder begrenzte Auszüge desselben Projekts prüfen
### Gespeicherte Pläne auflisten
Um eine Aufgabe vor dem Fortsetzen zu finden, führe `sh "<skill-dir>/scripts/set-active-plan.sh" --list` aus, unter Windows PowerShell `& "<skill-dir>/scripts/set-active-plan.ps1" -List`. Ersetze `<skill-dir>` durch das Installationsverzeichnis dieses Skills und bleibe im Projektstamm als aktuellem Arbeitsverzeichnis.
Der Befehl liest nur und zeigt benannte Pläne samt Phasenfortschritt unter `.planning/` im aktuellen Arbeitsverzeichnis. `[active]` kennzeichnet den gemeinsamen Standardzeiger; er bindet keine Sitzung an einen Plan. Parallele Aufgaben benötigen weiterhin eine eigene `PLAN_ID` pro Host oder getrennte Worktrees.
## Sicherheitsgrenzen
Dieser Skill verwendet einen PreToolUse-Hook, der `task_plan.md` vor jedem Tool-Aufruf neu einliest. In `task_plan.md` geschriebene Inhalte werden wiederholt in den Kontext eingespeist, was sie zu einem lohnenden Ziel für indirekte Prompt-Injektion macht.
+266 -16
View File
@@ -1,27 +1,245 @@
# planning-with-files: set or display the active plan pointer (PowerShell).
# planning-with-files: set, display, or list the active plan pointer (PowerShell).
#
# Usage:
# .\set-active-plan.ps1 <plan_id> - pin .planning\.active_plan to plan_id
# .\set-active-plan.ps1 - print the current active plan (if any)
# .\set-active-plan.ps1 -List - list available named plans and phase counts
# .\set-active-plan.ps1 --list - equivalent to -List and -l
param(
[string]$PlanId = ""
[Parameter(Position = 0)]
[string]$PlanId = "",
[Alias('l', '-list')]
[switch]$List,
[Alias('h', '-help')]
[switch]$Help
)
$PlanRoot = Join-Path (Get-Location) ".planning"
$ProjectRoot = (Get-Location).Path
# Windows PowerShell 5.1 can silently change directories when -File inherits
# a cwd containing wildcard characters. Recover that physical cwd only for a
# direct invocation of this script; interactive Set-Location remains primary.
if ($PSVersionTable.PSVersion.Major -eq 5 -and
[System.Management.Automation.WildcardPattern]::ContainsWildcardCharacters([Environment]::CurrentDirectory)) {
$processArgs = [Environment]::GetCommandLineArgs()
for ($index = 0; $index -lt ($processArgs.Length - 1); $index++) {
if ($processArgs[$index] -ieq '-File') {
$entryScript = $processArgs[$index + 1]
if (-not [IO.Path]::IsPathRooted($entryScript)) {
$entryScript = Join-Path ([Environment]::CurrentDirectory) $entryScript
}
if ([IO.Path]::GetFullPath($entryScript) -eq [IO.Path]::GetFullPath($PSCommandPath)) {
$ProjectRoot = [Environment]::CurrentDirectory
}
break
}
}
}
$PlanRoot = Join-Path $ProjectRoot ".planning"
$ActiveFile = Join-Path $PlanRoot ".active_plan"
$script:IsWindowsHost = [Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT
# Resolve-Path can retain a junction's spelling. Like resolve-plan-dir.ps1,
# open the actual filesystem object before checking the project boundary.
# The Windows handle supports both directories and task_plan.md files.
function Get-FinalPath {
param([string]$Path)
if (-not ("PwfPlanListingNative" -as [type])) {
Add-Type -ErrorAction Stop -TypeDefinition @'
using System;
using System.ComponentModel;
using System.Runtime.InteropServices;
using System.Text;
using Microsoft.Win32.SafeHandles;
public static class PwfPlanListingNative {
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern SafeFileHandle CreateFileW(
string name, uint access, uint share, IntPtr security,
uint creation, uint flags, IntPtr template);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern uint GetFinalPathNameByHandleW(
SafeFileHandle handle, StringBuilder path, uint length, uint flags);
[DllImport("libc", SetLastError = true)]
private static extern IntPtr realpath(string path, IntPtr resolved);
[DllImport("libc")]
private static extern void free(IntPtr pointer);
public static string FinalPath(string path) {
if (Environment.OSVersion.Platform != PlatformID.Win32NT) {
IntPtr resolved = realpath(path, IntPtr.Zero);
if (resolved == IntPtr.Zero) throw new Win32Exception(Marshal.GetLastWin32Error());
try { return Marshal.PtrToStringAnsi(resolved); }
finally { free(resolved); }
}
// OPEN_EXISTING, FILE_SHARE_READ | WRITE | DELETE, BACKUP_SEMANTICS.
using (SafeFileHandle handle = CreateFileW(
path, 0, 7, IntPtr.Zero, 3, 0x02000000, IntPtr.Zero)) {
if (handle.IsInvalid) throw new Win32Exception(Marshal.GetLastWin32Error());
StringBuilder buffer = new StringBuilder(32768);
uint length = GetFinalPathNameByHandleW(handle, buffer, (uint)buffer.Capacity, 0);
if (length == 0 || length >= buffer.Capacity)
throw new Win32Exception(Marshal.GetLastWin32Error());
string result = buffer.ToString();
if (result.StartsWith(@"\\?\UNC\", StringComparison.OrdinalIgnoreCase))
return @"\\" + result.Substring(8);
if (result.StartsWith(@"\\?\", StringComparison.OrdinalIgnoreCase))
return result.Substring(4);
return result;
}
}
}
'@
}
return [PwfPlanListingNative]::FinalPath($Path)
}
function Test-WithinRoot {
param([string]$Path)
try {
$rootReal = (Get-FinalPath $ProjectRoot).TrimEnd('\', '/')
$pathReal = (Get-FinalPath $Path).TrimEnd('\', '/')
$comparison = [StringComparison]::Ordinal
if ($script:IsWindowsHost) { $comparison = [StringComparison]::OrdinalIgnoreCase }
return $pathReal.Equals($rootReal, $comparison) -or
$pathReal.StartsWith($rootReal + [IO.Path]::DirectorySeparatorChar, $comparison)
} catch {
return $false
}
}
function Test-ValidSlug {
param([string]$Name)
return $Name -cmatch '^[A-Za-z0-9_][A-Za-z0-9._-]*\z'
}
function Test-SafeActiveFile {
param([switch]$AllowLink)
$item = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
if (-not $item) { return $false }
# Reading may follow a verified in-project link; writing must not.
return -not $item.PSIsContainer -and
($AllowLink -or (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0)) -and
(Test-WithinRoot $ActiveFile)
}
function Get-CurrentActivePlan {
if (-not (Test-SafeActiveFile -AllowLink)) { return "" }
try {
$current = ([string](Get-Content -LiteralPath $ActiveFile -Raw -Encoding UTF8 -ErrorAction Stop)).Trim([char[]]"`r`n")
if (Test-ValidSlug $current) { return $current }
} catch { }
return ""
}
function Add-PhaseStatus {
param([hashtable]$Counts, $Phase)
if ($null -eq $Phase) { return }
$status = $Phase.Inline
if ($Phase.Primary) { $status = $Phase.Primary }
if ($status) { $Counts[$status]++ }
}
function Get-PhaseStatus {
param([string]$PlanFile)
$counts = @{ total = 0; complete = 0; in_progress = 0; pending = 0 }
$phase = $null
$fence = ""
$fenceLength = 0
# The shipped translations keep status identifiers in English. Unicode
# escapes preserve Windows PowerShell 5.1 compatibility without a BOM.
$phaseLabel = '(?:Phase|Fase|\u0627\u0644\u0645\u0631\u062d\u0644\u0629|\u9636\u6bb5|\u968e\u6bb5)'
$statusLabel = '(?:Status:|Estado:|\u0627\u0644\u062d\u0627\u0644\u0629:|\u72b6\u6001\uff1a|\u72c0\u614b\uff1a)'
# Read once; an empty file is a valid plan with zero phases.
$content = [string](Get-Content -LiteralPath $PlanFile -Raw -Encoding UTF8 -ErrorAction Stop)
foreach ($line in ($content -split '\r?\n')) {
if ($fence) {
$closing = '^ {0,3}' + [regex]::Escape($fence) + '{' + $fenceLength + ',}[ \t]*$'
if ($line -match $closing) { $fence = "" }
continue
}
if ($line -match '^ {0,3}(`{3,}|~{3,})') {
$fence = $Matches[1].Substring(0, 1)
$fenceLength = $Matches[1].Length
continue
}
if ($line -cmatch ('^ {0,3}###[ \t]+' + $phaseLabel + '[ \t]+[0-9]+(?:[^0-9A-Za-z_]|$)')) {
Add-PhaseStatus $counts $phase
$phase = @{ Primary = ""; Inline = "" }
$counts.total++
if ($line -cmatch '\[(complete|in_progress|pending)\]') {
$phase.Inline = $Matches[1]
}
continue
}
if ($line -match '^ {0,3}#{1,3}(?:[ \t]+|$)') {
Add-PhaseStatus $counts $phase
$phase = $null
continue
}
if ($null -ne $phase -and -not $phase.Primary -and
$line -cmatch ('^ {0,3}(?:-[ \t]+)?\*\*' + $statusLabel + '\*\*[ \t]+(complete|in_progress|pending)(?:[ \t]|$)')) {
$phase.Primary = $Matches[1]
}
}
Add-PhaseStatus $counts $phase
return "$($counts.complete)/$($counts.total) complete, $($counts.in_progress) in_progress, $($counts.pending) pending"
}
function Show-PlanList {
if (-not (Test-Path -LiteralPath $PlanRoot -PathType Container)) {
Write-Output "No planning directory found."
return
}
if (-not (Test-WithinRoot $PlanRoot)) {
Write-Error "Error: planning directory is outside the project or cannot be verified."
exit 1
}
$active = Get-CurrentActivePlan
Write-Output "Available plans:"
Write-Output "[active] marks the shared .active_plan pointer; listing does not bind this session."
$found = $false
foreach ($plan in (Get-ChildItem -LiteralPath $PlanRoot -Directory -ErrorAction SilentlyContinue | Sort-Object Name)) {
if (-not (Test-ValidSlug $plan.Name) -or -not (Test-WithinRoot $plan.FullName)) { continue }
$planFile = Join-Path $plan.FullName "task_plan.md"
if (-not (Test-Path -LiteralPath $planFile -PathType Leaf) -or -not (Test-WithinRoot $planFile)) { continue }
try { $status = Get-PhaseStatus $planFile } catch { continue }
$marker = ""
if ($plan.Name -ceq $active) { $marker = " [active]" }
Write-Output "- $($plan.Name)$marker - $status"
$found = $true
}
if (-not $found) { Write-Output "No named plans found." }
}
if ($Help -or $PlanId -eq "--help" -or $PlanId -eq "-h") {
Write-Output "Usage: set-active-plan.ps1 [-List|-l|--list|PLAN_ID]"
exit 0
}
if ($List -or $PlanId -eq "--list" -or $PlanId -eq "-l") {
if ($List -and $PlanId) {
Write-Error "Error: list plans or set PLAN_ID in separate calls."
exit 1
}
Show-PlanList
exit 0
}
if ($PlanId -eq "") {
if (Test-Path $ActiveFile) {
$current = (Get-Content $ActiveFile -Raw -Encoding UTF8).Trim()
$current = ""
if ((Test-Path -LiteralPath $PlanRoot -PathType Container) -and -not (Test-WithinRoot $PlanRoot)) {
Write-Error "Error: planning directory is outside the project or cannot be verified."
exit 1
}
if (Test-WithinRoot $PlanRoot) { $current = Get-CurrentActivePlan }
if ($current) {
$planDir = Join-Path $PlanRoot $current
if ($current -ne "" -and (Test-Path $planDir)) {
if ((Test-Path -LiteralPath $planDir -PathType Container) -and (Test-WithinRoot $planDir)) {
Write-Output "Active plan: $current"
Write-Output "Path: $planDir"
} elseif ($current -ne "") {
Write-Output "Active plan pointer: $current (directory not found - stale pointer)"
} else {
Write-Output "No active plan set."
Write-Output "Active plan pointer: $current (directory not found or outside project - stale pointer)"
}
} else {
Write-Output "No active plan set."
@@ -29,21 +247,53 @@ if ($PlanId -eq "") {
exit 0
}
if (-not (Test-ValidSlug $PlanId)) {
Write-Error "Error: invalid plan ID. Use letters, numbers, underscores, dots, or hyphens; start with a letter, number, or underscore."
exit 1
}
$PlanDir = Join-Path $PlanRoot $PlanId
if (-not (Test-Path $PlanDir)) {
if (-not (Test-Path -LiteralPath $PlanDir -PathType Container)) {
Write-Error "Error: plan directory not found: $PlanDir"
Write-Error "Run: init-session.sh `"$PlanId`" to create it, or check .planning\ for available plans."
exit 1
}
if (-not (Test-Path $PlanRoot)) {
New-Item -ItemType Directory -Path $PlanRoot -Force | Out-Null
if (-not (Test-WithinRoot $PlanRoot) -or -not (Test-WithinRoot $PlanDir)) {
Write-Error "Error: plan directory must remain within the project."
exit 1
}
$activeItem = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
if ($activeItem -and -not (Test-SafeActiveFile)) {
Write-Error "Error: the active plan pointer must be a regular file within the project."
exit 1
}
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($ActiveFile, $PlanId, $utf8NoBom)
# Replace the directory entry instead of truncating an existing inode: a
# hardlinked pointer must not overwrite another file. The exclusive temporary
# file lives beside the pointer, keeping replacement on the same filesystem.
$tempFile = Join-Path $PlanRoot ('.active_plan.' + [guid]::NewGuid().ToString('N') + '.tmp')
$createdTemp = $false
try {
$stream = [IO.File]::Open($tempFile, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
$createdTemp = $true
try {
$bytes = $utf8NoBom.GetBytes($PlanId)
$stream.Write($bytes, 0, $bytes.Length)
$stream.Flush()
} finally { $stream.Dispose() }
if ($activeItem) {
[IO.File]::Replace($tempFile, $ActiveFile, [NullString]::Value)
} else {
[IO.File]::Move($tempFile, $ActiveFile)
}
} catch {
Write-Error "Error: could not set the active plan pointer: $($_.Exception.Message)"
exit 1
} finally {
if ($createdTemp -and (Test-Path -LiteralPath $tempFile)) {
Remove-Item -LiteralPath $tempFile -Force -ErrorAction SilentlyContinue
}
}
Write-Output "Active plan set to: $PlanId"
Write-Output "Path: $PlanDir"
Write-Output ""
+323 -38
View File
@@ -1,50 +1,335 @@
#!/bin/sh
# planning-with-files: set or display the active plan pointer.
#
# Usage:
# set-active-plan.sh <plan_id> — pin .planning/.active_plan to plan_id
# set-active-plan.sh — print the current active plan (if any)
#
# The active plan is stored in .planning/.active_plan and is read by
# resolve-plan-dir.sh when no $PLAN_ID env var is set.
set -e
# List saved named plans, show the shared pointer, or change that pointer.
# Usage: set-active-plan.sh [--list|-l|PLAN_ID]
# Operates on the current project; listing never binds a host or injects data.
set -eu
PLAN_ROOT="${PWD}/.planning"
ACTIVE_FILE="${PLAN_ROOT}/.active_plan"
PWF_ROOT_PIN=""
# No args → show current active plan
if [ "${1:-}" = "" ]; then
if [ -f "${ACTIVE_FILE}" ]; then
plan_id="$(tr -d '\r\n' < "${ACTIVE_FILE}")"
if [ -n "${plan_id}" ] && [ -d "${PLAN_ROOT}/${plan_id}" ]; then
echo "Active plan: ${plan_id}"
echo "Path: ${PLAN_ROOT}/${plan_id}"
elif [ -n "${plan_id}" ]; then
echo "Active plan pointer: ${plan_id} (directory not found — stale pointer)"
else
echo "No active plan set."
fi
# Use the resolver canonicalization policy without running plan selection.
slug_is_valid() {
case "$1" in
'') return 1 ;;
*[!A-Za-z0-9._-]*) return 1 ;;
[A-Za-z0-9_]*) return 0 ;;
esac
return 1
}
# Pure-sh backslash-to-forward-slash normalizer; result lands in $NORM_OUT.
# Windows-native coreutils builds (e.g. C:\Program Files\coreutils on PATH
# ahead of Git's usr/bin) canonicalize MSYS-style /c/... input to C:\-style
# backslash output. The containment prefix match below is written with forward
# slashes, so without this normalization every canonical pair mismatches and
# resolution silently fails. On POSIX systems paths contain no backslash and
# this is the identity. A literal backslash in a Unix filename normalizes to
# "/" and at worst fails containment — the safe direction. No subshell, no
# fork: plain parameter expansion in a loop.
norm_slashes() {
NORM_OUT=""
_ns_rest="$1"
while :; do
case "${_ns_rest}" in
*\\*)
NORM_OUT="${NORM_OUT}${_ns_rest%%\\*}/"
_ns_rest="${_ns_rest#*\\}"
;;
*)
NORM_OUT="${NORM_OUT}${_ns_rest}"
break
;;
esac
done
}
# Return true when a candidate path names the Microsoft Store WindowsApps
# directory. Store app aliases are not stable interpreter binaries and may
# present as executable while refusing script execution. Matching is
# case-insensitive and works before or after Windows slash normalization.
is_windowsapps_path() {
norm_slashes "$1"
case "${NORM_OUT}" in
[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]|\
[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]/*|\
*/[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]|\
*/[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]/*) return 0 ;;
esac
return 1
}
# Select only an interpreter path the caller explicitly trusted.
# PWF_TRUSTED_PYTHON is preferred; PYTHON_BIN remains a compatibility alias.
# PATH discovery is intentionally forbidden because resolver hooks can run in
# repositories that control PATH. Windows-native absolute paths are converted
# with Git Bash's fixed system cygpath, never a PATH-selected shim.
trusted_python() {
for _tp_candidate in "${PWF_TRUSTED_PYTHON:-}" "${PYTHON_BIN:-}"; do
[ -n "${_tp_candidate}" ] || continue
case "${_tp_candidate}" in
\\\\*|//*) continue ;;
[A-Za-z]:[\\/]*)
is_windowsapps_path "${_tp_candidate}" && continue
_tp_cygpath="/usr/bin/cygpath.exe"
[ -f "${_tp_cygpath}" ] && [ -x "${_tp_cygpath}" ] || continue
_tp_candidate="$("${_tp_cygpath}" -u "${_tp_candidate}" 2>/dev/null)" \
|| continue
;;
/*) ;;
*) continue ;;
esac
is_windowsapps_path "${_tp_candidate}" && continue
[ -f "${_tp_candidate}" ] || continue
[ -x "${_tp_candidate}" ] || continue
printf "%s\n" "${_tp_candidate}"
return 0
done
return 1
}
# Portable path canonicalizer. realpath first (Linux, modern coreutils),
# then readlink -f (older GNU), then an explicitly trusted Python interpreter.
# Prints the canonical absolute path on success; prints nothing and returns 1
# on a full miss so containment fails closed. No Python spawn on the happy
# path: realpath/readlink cover Linux, WSL, Git-Bash, and modern macOS.
canonicalize() {
target="$1"
if command -v realpath >/dev/null 2>&1; then
out="$(realpath "${target}" 2>/dev/null)" && [ -n "${out}" ] && {
printf "%s\n" "${out}"; return 0; }
fi
if command -v readlink >/dev/null 2>&1; then
out="$(readlink -f "${target}" 2>/dev/null)" && [ -n "${out}" ] && {
printf "%s\n" "${out}"; return 0; }
fi
_canonical_python="$(trusted_python)" || _canonical_python=""
if [ -n "${_canonical_python}" ]; then
out="$("${_canonical_python}" -I -c "import os,sys;print(os.path.realpath(sys.argv[1]))" "${target}" 2>/dev/null)" \
&& [ -n "${out}" ] && { printf "%s\n" "${out}"; return 0; }
fi
return 1
}
# Containment guard (security A1.3): a resolved plan dir must canonicalize to a
# path under the project root (the CWD the script runs from). A symlink inside
# a valid slug dir pointing at /etc or outside the workspace would otherwise let
# the hooks hash and inject an arbitrary file. On any violation we return 1 so
# the caller treats the candidate as unresolved and falls back safely.
#
# The root canonicalizes via the relative token "." rather than the $PWD
# string. On some Windows/MSYS setups (8.3 short names, the /tmp mount alias)
# realpath("$PWD") and realpath(relative-candidate) resolve through different
# code paths and land on differently-spelled-but-equal targets, so the prefix
# match below fails and resolution silently goes dark. "." resolves through
# the same physical-cwd path candidates already use (same fix inject-plan.sh
# received earlier; the resolver kept the $PWD form until now). Both sides are
# backslash-normalized before comparison for Windows-native canonicalizers.
# The root is computed once per run: the newest-mtime scan calls this guard
# per plan dir, and each canonicalize costs a process spawn on Windows.
#
# With a PWF_PLAN_ROOT pin (issue #212) containment is checked against THAT
# root instead of the cwd: candidates arrive ${PWF_PLAN_ROOT}/-prefixed, so
# both sides canonicalize through the same path spelling. Unpinned keeps the
# relative "." root — byte-identical to the legacy check.
ROOT_REAL=""
ROOT_REAL_SET=0
is_within_root() {
candidate="$1"
if [ "${ROOT_REAL_SET}" = "0" ]; then
ROOT_REAL="$(canonicalize "${PWF_ROOT_PIN:-.}")" || ROOT_REAL=""
norm_slashes "${ROOT_REAL}"
ROOT_REAL="${NORM_OUT}"
ROOT_REAL_SET=1
fi
# Canonicalize the candidate through its cwd-RELATIVE form whenever it
# lives under ${PWD}. The candidate string is built from ${PWD} (an MSYS
# long-form spelling), while the root canonicalizes from "." (the process
# cwd, which a caller may have set with an 8.3 short-form string). A
# Windows-native realpath does not unify those spellings, so canonicalizing
# both sides from the same cwd base is the only spelling-stable comparison.
# The emitted result keeps the original absolute candidate — only the
# containment check uses the relative form.
# Pinned resolution skips the rewrite: candidate and root then share the
# ${PWF_PLAN_ROOT} spelling, so both canonicalize directly from it.
if [ -n "${PWF_ROOT_PIN}" ]; then
check_target="${candidate}"
else
echo "No active plan set."
case "${candidate}" in
"${PWD}"/*) check_target=".${candidate#"${PWD}"}" ;;
*) check_target="${candidate}" ;;
esac
fi
cand_real="$(canonicalize "${check_target}")" || cand_real=""
norm_slashes "${cand_real}"
cand_real="${NORM_OUT}"
if [ -z "${ROOT_REAL}" ] || [ -z "${cand_real}" ]; then
# Slug validation blocks textual traversal, but only successful
# canonicalization can rule out a symlink/junction escape.
return 1
fi
case "${cand_real}" in
"${ROOT_REAL}"|"${ROOT_REAL}"/*) return 0 ;;
*) return 1 ;;
esac
}
# Each phase contributes at most one status. An explicit Status line wins
# over an inline heading marker. Fenced examples and unrelated sections are
# not phases. Keep this parser aligned with the PowerShell helper.
phase_status() {
awk '
function finish() {
if (phase) {
status = primary != "" ? primary : inline_status
if (status == "complete") complete++
else if (status == "in_progress") in_progress++
else if (status == "pending") pending++
}
phase = 0; primary = ""; inline_status = ""
}
{
line = $0
sub(/\r$/, "", line)
trimmed = line
sub(/^ */, "", trimmed)
if (line ~ /^ ? ? ?```/ || line ~ /^ ? ? ?~~~/) {
marker = substr(trimmed, 1, 1)
run = 0
while (substr(trimmed, run + 1, 1) == marker) run++
if (fence == "") { fence = marker; fence_length = run }
else if (marker == fence && run >= fence_length && substr(trimmed, run + 1) ~ /^[ \t]*$/) fence = ""
next
}
if (fence != "") next
if (line ~ /^ ? ? ?###[ \t]+(Phase|Fase|المرحلة|阶段|階段)[ \t]+[0-9]+([^0-9A-Za-z_]|$)/) {
finish(); phase = 1; total++
if (match(line, /\[(complete|in_progress|pending)\]/))
inline_status = substr(line, RSTART + 1, RLENGTH - 2)
} else if (line ~ /^ ? ? ?(###|##|#)([ \t]|$)/) {
finish()
} else if (phase && primary == "" && line ~ /^ ? ? ?(-[ \t]+)?\*\*(Status:|Estado:|الحالة:|状态:|狀態:)\*\*[ \t]+(complete|in_progress|pending)([ \t]|$)/) {
sub(/^ ? ? ?(-[ \t]+)?\*\*(Status:|Estado:|الحالة:|状态:|狀態:)\*\*[ \t]+/, "", line)
sub(/[ \t].*$/, "", line)
primary = line
}
}
END {
finish()
printf "%d/%d complete, %d in_progress, %d pending", complete, total, in_progress, pending
}
' < "$1"
}
current_active() {
if [ -f "${ACTIVE_FILE}" ] && is_within_root "${ACTIVE_FILE}"; then
_current="$(tr '\r' '\n' < "${ACTIVE_FILE}")"
# Windows editors and older PowerShell defaults can leave a UTF-8 BOM.
# Treat it as an encoding marker, not part of the shared plan slug.
_utf8_bom="$(printf '\357\273\277')"
case "${_current}" in
"${_utf8_bom}"*) _current="${_current#"${_utf8_bom}"}" ;;
esac
slug_is_valid "${_current}" && printf '%s\n' "${_current}"
fi
return 0
}
list_plans() {
if [ ! -d "${PLAN_ROOT}" ]; then
printf '%s\n' 'No planning directory found.'
return 0
fi
if ! is_within_root "${PLAN_ROOT}"; then
printf '%s\n' 'Error: planning directory is outside the project or cannot be verified.' >&2
return 1
fi
_active="$(current_active)"
_found=0
printf '%s\n' 'Available plans:'
for _dir in "${PLAN_ROOT}"/*; do
[ -d "${_dir}" ] || continue
_id="${_dir##*/}"
slug_is_valid "${_id}" || continue
is_within_root "${_dir}" || continue
_plan_file="${_dir}/task_plan.md"
[ -f "${_plan_file}" ] && [ -r "${_plan_file}" ] || continue
is_within_root "${_plan_file}" || continue
_status="$(phase_status "${_plan_file}")" || continue
_marker=''
if [ "${_id}" = "${_active}" ]; then _marker=' [active]'; fi
printf '%s\n' "- ${_id}${_marker} - ${_status}"
_found=1
done
if [ "${_found}" -eq 0 ]; then
printf '%s\n' 'No named plans found.'
else
printf '%s\n' '[active] marks the shared default pointer. Set PLAN_ID to pin a session.'
fi
}
if [ "$#" -gt 1 ]; then
printf '%s\n' 'Error: list plans or set PLAN_ID in separate calls.' >&2
exit 1
fi
case "${1:-}" in
--list|-l) list_plans; exit $? ;;
--help|-h)
printf '%s\n' 'Usage: set-active-plan.sh [--list|PLAN_ID]' \
'Lists saved named plans in the current project without selecting a plan.'
exit 0 ;;
esac
if [ "${1:-}" = '' ]; then
if [ -d "${PLAN_ROOT}" ] && ! is_within_root "${PLAN_ROOT}"; then
printf '%s\n' 'Error: planning directory is outside the project or cannot be verified.' >&2
exit 1
fi
plan_id="$(current_active)"
if [ -n "${plan_id}" ] && [ -d "${PLAN_ROOT}/${plan_id}" ] && is_within_root "${PLAN_ROOT}/${plan_id}"; then
printf '%s\n' "Active plan: ${plan_id}" "Path: ${PLAN_ROOT}/${plan_id}"
elif [ -n "${plan_id}" ]; then
printf '%s\n' "Active plan pointer: ${plan_id} (directory not found or outside project - stale pointer)"
else
printf '%s\n' 'No active plan set.'
fi
exit 0
fi
PLAN_ID="$1"
PLAN_DIR="${PLAN_ROOT}/${PLAN_ID}"
if [ ! -d "${PLAN_DIR}" ]; then
echo "Error: plan directory not found: ${PLAN_DIR}" >&2
echo "Run: init-session.sh \"${PLAN_ID}\" to create it, or check .planning/ for available plans." >&2
if ! slug_is_valid "${PLAN_ID}"; then
printf '%s\n' 'Error: invalid plan ID. Use a named directory under .planning.' >&2
exit 1
fi
mkdir -p "${PLAN_ROOT}"
printf "%s\n" "${PLAN_ID}" > "${ACTIVE_FILE}"
echo "Active plan set to: ${PLAN_ID}"
echo "Path: ${PLAN_DIR}"
echo ""
echo "To pin this terminal session only:"
echo " export PLAN_ID=${PLAN_ID}"
PLAN_DIR="${PLAN_ROOT}/${PLAN_ID}"
if [ ! -d "${PLAN_DIR}" ]; then
printf '%s\n' "Error: plan directory not found: ${PLAN_DIR}" \
"Run: init-session.sh \"${PLAN_ID}\" to create it, or use --list to see available plans." >&2
exit 1
fi
if ! is_within_root "${PLAN_ROOT}" || ! is_within_root "${PLAN_DIR}"; then
printf '%s\n' 'Error: plan directory is outside the project or cannot be verified.' >&2
exit 1
fi
# A pre-existing pointer must itself be a contained regular file before writing.
if { [ -e "${ACTIVE_FILE}" ] || [ -L "${ACTIVE_FILE}" ]; } &&
{ [ -L "${ACTIVE_FILE}" ] || [ ! -f "${ACTIVE_FILE}" ] || ! is_within_root "${ACTIVE_FILE}"; }; then
printf '%s\n' 'Error: active plan pointer is not a safe file inside the project.' >&2
exit 1
fi
# Replace the pointer atomically instead of truncating a possible hardlink.
# mktemp creates a private, exclusive file beside the destination.
temp_file="$(mktemp "${PLAN_ROOT}/.active_plan.XXXXXX")" || {
printf '%s\n' 'Error: could not create the active plan pointer.' >&2
exit 1
}
trap 'rm -f "${temp_file}"' EXIT
trap 'exit 1' HUP INT TERM
printf '%s\n' "${PLAN_ID}" > "${temp_file}"
if ! mv -f "${temp_file}" "${ACTIVE_FILE}"; then
printf '%s\n' 'Error: could not replace the active plan pointer.' >&2
exit 1
fi
trap - EXIT HUP INT TERM
printf '%s\n' "Active plan set to: ${PLAN_ID}" "Path: ${PLAN_DIR}" '' \
'To pin this terminal session only:' " export PLAN_ID=${PLAN_ID}"
+7 -1
View File
@@ -36,7 +36,7 @@ hooks:
- type: command
command: "SH=\"\"; for c in \"${PWF_SCRIPT_DIR}/skill-hook.sh\" \"${CLAUDE_SKILL_DIR}/scripts/skill-hook.sh\" \"$HOME/.claude/skills/planning-with-files-es/scripts/skill-hook.sh\" \"$HOME/.claude/skills/planning-with-files/scripts/skill-hook.sh\" \"$HOME/.claude/plugins/marketplaces/planning-with-files/scripts/skill-hook.sh\"; do [ -f \"$c\" ] && { SH=\"$c\"; break; }; done; [ -n \"$SH\" ] && sh \"$SH\" --event=precompact; exit 0"
metadata:
version: "3.17.2"
version: "3.18.2"
---
# Sistema de Planificación con Archivos
@@ -237,6 +237,12 @@ Scripts auxiliares de automatización:
- `scripts/check-complete.sh` — Verifica si todas las fases están completas
- `scripts/session-catchup.py`: sin opciones no accede al historial; `--metadata` inspecciona solo metadatos locales del mismo proyecto y `--replay` reproduce extractos limitados y enmarcados cuando el usuario lo solicita de forma explícita
### Listar planes guardados
Para encontrar una tarea antes de retomarla, ejecuta `sh "<skill-dir>/scripts/set-active-plan.sh" --list` o, en Windows PowerShell, `& "<skill-dir>/scripts/set-active-plan.ps1" -List`. Sustituye `<skill-dir>` por el directorio de instalación de este skill y mantén la raíz del proyecto como directorio de trabajo actual.
Este comando de solo lectura muestra los planes con nombre y el progreso de sus fases en `.planning/` del directorio actual. `[active]` marca el puntero predeterminado compartido; no vincula una sesión a un plan. Las tareas simultáneas siguen necesitando una `PLAN_ID` por host o árboles de trabajo separados.
## Límites de seguridad
Este skill usa un hook PreToolUse para releer `task_plan.md` antes de cada llamada a herramienta. El contenido escrito en `task_plan.md` se inyecta repetidamente en el contexto, lo que lo convierte en un objetivo de alto valor para inyección indirecta de prompts.
+266 -16
View File
@@ -1,27 +1,245 @@
# planning-with-files: set or display the active plan pointer (PowerShell).
# planning-with-files: set, display, or list the active plan pointer (PowerShell).
#
# Usage:
# .\set-active-plan.ps1 <plan_id> - pin .planning\.active_plan to plan_id
# .\set-active-plan.ps1 - print the current active plan (if any)
# .\set-active-plan.ps1 -List - list available named plans and phase counts
# .\set-active-plan.ps1 --list - equivalent to -List and -l
param(
[string]$PlanId = ""
[Parameter(Position = 0)]
[string]$PlanId = "",
[Alias('l', '-list')]
[switch]$List,
[Alias('h', '-help')]
[switch]$Help
)
$PlanRoot = Join-Path (Get-Location) ".planning"
$ProjectRoot = (Get-Location).Path
# Windows PowerShell 5.1 can silently change directories when -File inherits
# a cwd containing wildcard characters. Recover that physical cwd only for a
# direct invocation of this script; interactive Set-Location remains primary.
if ($PSVersionTable.PSVersion.Major -eq 5 -and
[System.Management.Automation.WildcardPattern]::ContainsWildcardCharacters([Environment]::CurrentDirectory)) {
$processArgs = [Environment]::GetCommandLineArgs()
for ($index = 0; $index -lt ($processArgs.Length - 1); $index++) {
if ($processArgs[$index] -ieq '-File') {
$entryScript = $processArgs[$index + 1]
if (-not [IO.Path]::IsPathRooted($entryScript)) {
$entryScript = Join-Path ([Environment]::CurrentDirectory) $entryScript
}
if ([IO.Path]::GetFullPath($entryScript) -eq [IO.Path]::GetFullPath($PSCommandPath)) {
$ProjectRoot = [Environment]::CurrentDirectory
}
break
}
}
}
$PlanRoot = Join-Path $ProjectRoot ".planning"
$ActiveFile = Join-Path $PlanRoot ".active_plan"
$script:IsWindowsHost = [Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT
# Resolve-Path can retain a junction's spelling. Like resolve-plan-dir.ps1,
# open the actual filesystem object before checking the project boundary.
# The Windows handle supports both directories and task_plan.md files.
function Get-FinalPath {
param([string]$Path)
if (-not ("PwfPlanListingNative" -as [type])) {
Add-Type -ErrorAction Stop -TypeDefinition @'
using System;
using System.ComponentModel;
using System.Runtime.InteropServices;
using System.Text;
using Microsoft.Win32.SafeHandles;
public static class PwfPlanListingNative {
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern SafeFileHandle CreateFileW(
string name, uint access, uint share, IntPtr security,
uint creation, uint flags, IntPtr template);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern uint GetFinalPathNameByHandleW(
SafeFileHandle handle, StringBuilder path, uint length, uint flags);
[DllImport("libc", SetLastError = true)]
private static extern IntPtr realpath(string path, IntPtr resolved);
[DllImport("libc")]
private static extern void free(IntPtr pointer);
public static string FinalPath(string path) {
if (Environment.OSVersion.Platform != PlatformID.Win32NT) {
IntPtr resolved = realpath(path, IntPtr.Zero);
if (resolved == IntPtr.Zero) throw new Win32Exception(Marshal.GetLastWin32Error());
try { return Marshal.PtrToStringAnsi(resolved); }
finally { free(resolved); }
}
// OPEN_EXISTING, FILE_SHARE_READ | WRITE | DELETE, BACKUP_SEMANTICS.
using (SafeFileHandle handle = CreateFileW(
path, 0, 7, IntPtr.Zero, 3, 0x02000000, IntPtr.Zero)) {
if (handle.IsInvalid) throw new Win32Exception(Marshal.GetLastWin32Error());
StringBuilder buffer = new StringBuilder(32768);
uint length = GetFinalPathNameByHandleW(handle, buffer, (uint)buffer.Capacity, 0);
if (length == 0 || length >= buffer.Capacity)
throw new Win32Exception(Marshal.GetLastWin32Error());
string result = buffer.ToString();
if (result.StartsWith(@"\\?\UNC\", StringComparison.OrdinalIgnoreCase))
return @"\\" + result.Substring(8);
if (result.StartsWith(@"\\?\", StringComparison.OrdinalIgnoreCase))
return result.Substring(4);
return result;
}
}
}
'@
}
return [PwfPlanListingNative]::FinalPath($Path)
}
function Test-WithinRoot {
param([string]$Path)
try {
$rootReal = (Get-FinalPath $ProjectRoot).TrimEnd('\', '/')
$pathReal = (Get-FinalPath $Path).TrimEnd('\', '/')
$comparison = [StringComparison]::Ordinal
if ($script:IsWindowsHost) { $comparison = [StringComparison]::OrdinalIgnoreCase }
return $pathReal.Equals($rootReal, $comparison) -or
$pathReal.StartsWith($rootReal + [IO.Path]::DirectorySeparatorChar, $comparison)
} catch {
return $false
}
}
function Test-ValidSlug {
param([string]$Name)
return $Name -cmatch '^[A-Za-z0-9_][A-Za-z0-9._-]*\z'
}
function Test-SafeActiveFile {
param([switch]$AllowLink)
$item = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
if (-not $item) { return $false }
# Reading may follow a verified in-project link; writing must not.
return -not $item.PSIsContainer -and
($AllowLink -or (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0)) -and
(Test-WithinRoot $ActiveFile)
}
function Get-CurrentActivePlan {
if (-not (Test-SafeActiveFile -AllowLink)) { return "" }
try {
$current = ([string](Get-Content -LiteralPath $ActiveFile -Raw -Encoding UTF8 -ErrorAction Stop)).Trim([char[]]"`r`n")
if (Test-ValidSlug $current) { return $current }
} catch { }
return ""
}
function Add-PhaseStatus {
param([hashtable]$Counts, $Phase)
if ($null -eq $Phase) { return }
$status = $Phase.Inline
if ($Phase.Primary) { $status = $Phase.Primary }
if ($status) { $Counts[$status]++ }
}
function Get-PhaseStatus {
param([string]$PlanFile)
$counts = @{ total = 0; complete = 0; in_progress = 0; pending = 0 }
$phase = $null
$fence = ""
$fenceLength = 0
# The shipped translations keep status identifiers in English. Unicode
# escapes preserve Windows PowerShell 5.1 compatibility without a BOM.
$phaseLabel = '(?:Phase|Fase|\u0627\u0644\u0645\u0631\u062d\u0644\u0629|\u9636\u6bb5|\u968e\u6bb5)'
$statusLabel = '(?:Status:|Estado:|\u0627\u0644\u062d\u0627\u0644\u0629:|\u72b6\u6001\uff1a|\u72c0\u614b\uff1a)'
# Read once; an empty file is a valid plan with zero phases.
$content = [string](Get-Content -LiteralPath $PlanFile -Raw -Encoding UTF8 -ErrorAction Stop)
foreach ($line in ($content -split '\r?\n')) {
if ($fence) {
$closing = '^ {0,3}' + [regex]::Escape($fence) + '{' + $fenceLength + ',}[ \t]*$'
if ($line -match $closing) { $fence = "" }
continue
}
if ($line -match '^ {0,3}(`{3,}|~{3,})') {
$fence = $Matches[1].Substring(0, 1)
$fenceLength = $Matches[1].Length
continue
}
if ($line -cmatch ('^ {0,3}###[ \t]+' + $phaseLabel + '[ \t]+[0-9]+(?:[^0-9A-Za-z_]|$)')) {
Add-PhaseStatus $counts $phase
$phase = @{ Primary = ""; Inline = "" }
$counts.total++
if ($line -cmatch '\[(complete|in_progress|pending)\]') {
$phase.Inline = $Matches[1]
}
continue
}
if ($line -match '^ {0,3}#{1,3}(?:[ \t]+|$)') {
Add-PhaseStatus $counts $phase
$phase = $null
continue
}
if ($null -ne $phase -and -not $phase.Primary -and
$line -cmatch ('^ {0,3}(?:-[ \t]+)?\*\*' + $statusLabel + '\*\*[ \t]+(complete|in_progress|pending)(?:[ \t]|$)')) {
$phase.Primary = $Matches[1]
}
}
Add-PhaseStatus $counts $phase
return "$($counts.complete)/$($counts.total) complete, $($counts.in_progress) in_progress, $($counts.pending) pending"
}
function Show-PlanList {
if (-not (Test-Path -LiteralPath $PlanRoot -PathType Container)) {
Write-Output "No planning directory found."
return
}
if (-not (Test-WithinRoot $PlanRoot)) {
Write-Error "Error: planning directory is outside the project or cannot be verified."
exit 1
}
$active = Get-CurrentActivePlan
Write-Output "Available plans:"
Write-Output "[active] marks the shared .active_plan pointer; listing does not bind this session."
$found = $false
foreach ($plan in (Get-ChildItem -LiteralPath $PlanRoot -Directory -ErrorAction SilentlyContinue | Sort-Object Name)) {
if (-not (Test-ValidSlug $plan.Name) -or -not (Test-WithinRoot $plan.FullName)) { continue }
$planFile = Join-Path $plan.FullName "task_plan.md"
if (-not (Test-Path -LiteralPath $planFile -PathType Leaf) -or -not (Test-WithinRoot $planFile)) { continue }
try { $status = Get-PhaseStatus $planFile } catch { continue }
$marker = ""
if ($plan.Name -ceq $active) { $marker = " [active]" }
Write-Output "- $($plan.Name)$marker - $status"
$found = $true
}
if (-not $found) { Write-Output "No named plans found." }
}
if ($Help -or $PlanId -eq "--help" -or $PlanId -eq "-h") {
Write-Output "Usage: set-active-plan.ps1 [-List|-l|--list|PLAN_ID]"
exit 0
}
if ($List -or $PlanId -eq "--list" -or $PlanId -eq "-l") {
if ($List -and $PlanId) {
Write-Error "Error: list plans or set PLAN_ID in separate calls."
exit 1
}
Show-PlanList
exit 0
}
if ($PlanId -eq "") {
if (Test-Path $ActiveFile) {
$current = (Get-Content $ActiveFile -Raw -Encoding UTF8).Trim()
$current = ""
if ((Test-Path -LiteralPath $PlanRoot -PathType Container) -and -not (Test-WithinRoot $PlanRoot)) {
Write-Error "Error: planning directory is outside the project or cannot be verified."
exit 1
}
if (Test-WithinRoot $PlanRoot) { $current = Get-CurrentActivePlan }
if ($current) {
$planDir = Join-Path $PlanRoot $current
if ($current -ne "" -and (Test-Path $planDir)) {
if ((Test-Path -LiteralPath $planDir -PathType Container) -and (Test-WithinRoot $planDir)) {
Write-Output "Active plan: $current"
Write-Output "Path: $planDir"
} elseif ($current -ne "") {
Write-Output "Active plan pointer: $current (directory not found - stale pointer)"
} else {
Write-Output "No active plan set."
Write-Output "Active plan pointer: $current (directory not found or outside project - stale pointer)"
}
} else {
Write-Output "No active plan set."
@@ -29,21 +247,53 @@ if ($PlanId -eq "") {
exit 0
}
if (-not (Test-ValidSlug $PlanId)) {
Write-Error "Error: invalid plan ID. Use letters, numbers, underscores, dots, or hyphens; start with a letter, number, or underscore."
exit 1
}
$PlanDir = Join-Path $PlanRoot $PlanId
if (-not (Test-Path $PlanDir)) {
if (-not (Test-Path -LiteralPath $PlanDir -PathType Container)) {
Write-Error "Error: plan directory not found: $PlanDir"
Write-Error "Run: init-session.sh `"$PlanId`" to create it, or check .planning\ for available plans."
exit 1
}
if (-not (Test-Path $PlanRoot)) {
New-Item -ItemType Directory -Path $PlanRoot -Force | Out-Null
if (-not (Test-WithinRoot $PlanRoot) -or -not (Test-WithinRoot $PlanDir)) {
Write-Error "Error: plan directory must remain within the project."
exit 1
}
$activeItem = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
if ($activeItem -and -not (Test-SafeActiveFile)) {
Write-Error "Error: the active plan pointer must be a regular file within the project."
exit 1
}
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($ActiveFile, $PlanId, $utf8NoBom)
# Replace the directory entry instead of truncating an existing inode: a
# hardlinked pointer must not overwrite another file. The exclusive temporary
# file lives beside the pointer, keeping replacement on the same filesystem.
$tempFile = Join-Path $PlanRoot ('.active_plan.' + [guid]::NewGuid().ToString('N') + '.tmp')
$createdTemp = $false
try {
$stream = [IO.File]::Open($tempFile, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
$createdTemp = $true
try {
$bytes = $utf8NoBom.GetBytes($PlanId)
$stream.Write($bytes, 0, $bytes.Length)
$stream.Flush()
} finally { $stream.Dispose() }
if ($activeItem) {
[IO.File]::Replace($tempFile, $ActiveFile, [NullString]::Value)
} else {
[IO.File]::Move($tempFile, $ActiveFile)
}
} catch {
Write-Error "Error: could not set the active plan pointer: $($_.Exception.Message)"
exit 1
} finally {
if ($createdTemp -and (Test-Path -LiteralPath $tempFile)) {
Remove-Item -LiteralPath $tempFile -Force -ErrorAction SilentlyContinue
}
}
Write-Output "Active plan set to: $PlanId"
Write-Output "Path: $PlanDir"
Write-Output ""
+323 -38
View File
@@ -1,50 +1,335 @@
#!/bin/sh
# planning-with-files: set or display the active plan pointer.
#
# Usage:
# set-active-plan.sh <plan_id> — pin .planning/.active_plan to plan_id
# set-active-plan.sh — print the current active plan (if any)
#
# The active plan is stored in .planning/.active_plan and is read by
# resolve-plan-dir.sh when no $PLAN_ID env var is set.
set -e
# List saved named plans, show the shared pointer, or change that pointer.
# Usage: set-active-plan.sh [--list|-l|PLAN_ID]
# Operates on the current project; listing never binds a host or injects data.
set -eu
PLAN_ROOT="${PWD}/.planning"
ACTIVE_FILE="${PLAN_ROOT}/.active_plan"
PWF_ROOT_PIN=""
# No args → show current active plan
if [ "${1:-}" = "" ]; then
if [ -f "${ACTIVE_FILE}" ]; then
plan_id="$(tr -d '\r\n' < "${ACTIVE_FILE}")"
if [ -n "${plan_id}" ] && [ -d "${PLAN_ROOT}/${plan_id}" ]; then
echo "Active plan: ${plan_id}"
echo "Path: ${PLAN_ROOT}/${plan_id}"
elif [ -n "${plan_id}" ]; then
echo "Active plan pointer: ${plan_id} (directory not found — stale pointer)"
else
echo "No active plan set."
fi
# Use the resolver canonicalization policy without running plan selection.
slug_is_valid() {
case "$1" in
'') return 1 ;;
*[!A-Za-z0-9._-]*) return 1 ;;
[A-Za-z0-9_]*) return 0 ;;
esac
return 1
}
# Pure-sh backslash-to-forward-slash normalizer; result lands in $NORM_OUT.
# Windows-native coreutils builds (e.g. C:\Program Files\coreutils on PATH
# ahead of Git's usr/bin) canonicalize MSYS-style /c/... input to C:\-style
# backslash output. The containment prefix match below is written with forward
# slashes, so without this normalization every canonical pair mismatches and
# resolution silently fails. On POSIX systems paths contain no backslash and
# this is the identity. A literal backslash in a Unix filename normalizes to
# "/" and at worst fails containment — the safe direction. No subshell, no
# fork: plain parameter expansion in a loop.
norm_slashes() {
NORM_OUT=""
_ns_rest="$1"
while :; do
case "${_ns_rest}" in
*\\*)
NORM_OUT="${NORM_OUT}${_ns_rest%%\\*}/"
_ns_rest="${_ns_rest#*\\}"
;;
*)
NORM_OUT="${NORM_OUT}${_ns_rest}"
break
;;
esac
done
}
# Return true when a candidate path names the Microsoft Store WindowsApps
# directory. Store app aliases are not stable interpreter binaries and may
# present as executable while refusing script execution. Matching is
# case-insensitive and works before or after Windows slash normalization.
is_windowsapps_path() {
norm_slashes "$1"
case "${NORM_OUT}" in
[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]|\
[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]/*|\
*/[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]|\
*/[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]/*) return 0 ;;
esac
return 1
}
# Select only an interpreter path the caller explicitly trusted.
# PWF_TRUSTED_PYTHON is preferred; PYTHON_BIN remains a compatibility alias.
# PATH discovery is intentionally forbidden because resolver hooks can run in
# repositories that control PATH. Windows-native absolute paths are converted
# with Git Bash's fixed system cygpath, never a PATH-selected shim.
trusted_python() {
for _tp_candidate in "${PWF_TRUSTED_PYTHON:-}" "${PYTHON_BIN:-}"; do
[ -n "${_tp_candidate}" ] || continue
case "${_tp_candidate}" in
\\\\*|//*) continue ;;
[A-Za-z]:[\\/]*)
is_windowsapps_path "${_tp_candidate}" && continue
_tp_cygpath="/usr/bin/cygpath.exe"
[ -f "${_tp_cygpath}" ] && [ -x "${_tp_cygpath}" ] || continue
_tp_candidate="$("${_tp_cygpath}" -u "${_tp_candidate}" 2>/dev/null)" \
|| continue
;;
/*) ;;
*) continue ;;
esac
is_windowsapps_path "${_tp_candidate}" && continue
[ -f "${_tp_candidate}" ] || continue
[ -x "${_tp_candidate}" ] || continue
printf "%s\n" "${_tp_candidate}"
return 0
done
return 1
}
# Portable path canonicalizer. realpath first (Linux, modern coreutils),
# then readlink -f (older GNU), then an explicitly trusted Python interpreter.
# Prints the canonical absolute path on success; prints nothing and returns 1
# on a full miss so containment fails closed. No Python spawn on the happy
# path: realpath/readlink cover Linux, WSL, Git-Bash, and modern macOS.
canonicalize() {
target="$1"
if command -v realpath >/dev/null 2>&1; then
out="$(realpath "${target}" 2>/dev/null)" && [ -n "${out}" ] && {
printf "%s\n" "${out}"; return 0; }
fi
if command -v readlink >/dev/null 2>&1; then
out="$(readlink -f "${target}" 2>/dev/null)" && [ -n "${out}" ] && {
printf "%s\n" "${out}"; return 0; }
fi
_canonical_python="$(trusted_python)" || _canonical_python=""
if [ -n "${_canonical_python}" ]; then
out="$("${_canonical_python}" -I -c "import os,sys;print(os.path.realpath(sys.argv[1]))" "${target}" 2>/dev/null)" \
&& [ -n "${out}" ] && { printf "%s\n" "${out}"; return 0; }
fi
return 1
}
# Containment guard (security A1.3): a resolved plan dir must canonicalize to a
# path under the project root (the CWD the script runs from). A symlink inside
# a valid slug dir pointing at /etc or outside the workspace would otherwise let
# the hooks hash and inject an arbitrary file. On any violation we return 1 so
# the caller treats the candidate as unresolved and falls back safely.
#
# The root canonicalizes via the relative token "." rather than the $PWD
# string. On some Windows/MSYS setups (8.3 short names, the /tmp mount alias)
# realpath("$PWD") and realpath(relative-candidate) resolve through different
# code paths and land on differently-spelled-but-equal targets, so the prefix
# match below fails and resolution silently goes dark. "." resolves through
# the same physical-cwd path candidates already use (same fix inject-plan.sh
# received earlier; the resolver kept the $PWD form until now). Both sides are
# backslash-normalized before comparison for Windows-native canonicalizers.
# The root is computed once per run: the newest-mtime scan calls this guard
# per plan dir, and each canonicalize costs a process spawn on Windows.
#
# With a PWF_PLAN_ROOT pin (issue #212) containment is checked against THAT
# root instead of the cwd: candidates arrive ${PWF_PLAN_ROOT}/-prefixed, so
# both sides canonicalize through the same path spelling. Unpinned keeps the
# relative "." root — byte-identical to the legacy check.
ROOT_REAL=""
ROOT_REAL_SET=0
is_within_root() {
candidate="$1"
if [ "${ROOT_REAL_SET}" = "0" ]; then
ROOT_REAL="$(canonicalize "${PWF_ROOT_PIN:-.}")" || ROOT_REAL=""
norm_slashes "${ROOT_REAL}"
ROOT_REAL="${NORM_OUT}"
ROOT_REAL_SET=1
fi
# Canonicalize the candidate through its cwd-RELATIVE form whenever it
# lives under ${PWD}. The candidate string is built from ${PWD} (an MSYS
# long-form spelling), while the root canonicalizes from "." (the process
# cwd, which a caller may have set with an 8.3 short-form string). A
# Windows-native realpath does not unify those spellings, so canonicalizing
# both sides from the same cwd base is the only spelling-stable comparison.
# The emitted result keeps the original absolute candidate — only the
# containment check uses the relative form.
# Pinned resolution skips the rewrite: candidate and root then share the
# ${PWF_PLAN_ROOT} spelling, so both canonicalize directly from it.
if [ -n "${PWF_ROOT_PIN}" ]; then
check_target="${candidate}"
else
echo "No active plan set."
case "${candidate}" in
"${PWD}"/*) check_target=".${candidate#"${PWD}"}" ;;
*) check_target="${candidate}" ;;
esac
fi
cand_real="$(canonicalize "${check_target}")" || cand_real=""
norm_slashes "${cand_real}"
cand_real="${NORM_OUT}"
if [ -z "${ROOT_REAL}" ] || [ -z "${cand_real}" ]; then
# Slug validation blocks textual traversal, but only successful
# canonicalization can rule out a symlink/junction escape.
return 1
fi
case "${cand_real}" in
"${ROOT_REAL}"|"${ROOT_REAL}"/*) return 0 ;;
*) return 1 ;;
esac
}
# Each phase contributes at most one status. An explicit Status line wins
# over an inline heading marker. Fenced examples and unrelated sections are
# not phases. Keep this parser aligned with the PowerShell helper.
phase_status() {
awk '
function finish() {
if (phase) {
status = primary != "" ? primary : inline_status
if (status == "complete") complete++
else if (status == "in_progress") in_progress++
else if (status == "pending") pending++
}
phase = 0; primary = ""; inline_status = ""
}
{
line = $0
sub(/\r$/, "", line)
trimmed = line
sub(/^ */, "", trimmed)
if (line ~ /^ ? ? ?```/ || line ~ /^ ? ? ?~~~/) {
marker = substr(trimmed, 1, 1)
run = 0
while (substr(trimmed, run + 1, 1) == marker) run++
if (fence == "") { fence = marker; fence_length = run }
else if (marker == fence && run >= fence_length && substr(trimmed, run + 1) ~ /^[ \t]*$/) fence = ""
next
}
if (fence != "") next
if (line ~ /^ ? ? ?###[ \t]+(Phase|Fase|المرحلة|阶段|階段)[ \t]+[0-9]+([^0-9A-Za-z_]|$)/) {
finish(); phase = 1; total++
if (match(line, /\[(complete|in_progress|pending)\]/))
inline_status = substr(line, RSTART + 1, RLENGTH - 2)
} else if (line ~ /^ ? ? ?(###|##|#)([ \t]|$)/) {
finish()
} else if (phase && primary == "" && line ~ /^ ? ? ?(-[ \t]+)?\*\*(Status:|Estado:|الحالة:|状态:|狀態:)\*\*[ \t]+(complete|in_progress|pending)([ \t]|$)/) {
sub(/^ ? ? ?(-[ \t]+)?\*\*(Status:|Estado:|الحالة:|状态:|狀態:)\*\*[ \t]+/, "", line)
sub(/[ \t].*$/, "", line)
primary = line
}
}
END {
finish()
printf "%d/%d complete, %d in_progress, %d pending", complete, total, in_progress, pending
}
' < "$1"
}
current_active() {
if [ -f "${ACTIVE_FILE}" ] && is_within_root "${ACTIVE_FILE}"; then
_current="$(tr '\r' '\n' < "${ACTIVE_FILE}")"
# Windows editors and older PowerShell defaults can leave a UTF-8 BOM.
# Treat it as an encoding marker, not part of the shared plan slug.
_utf8_bom="$(printf '\357\273\277')"
case "${_current}" in
"${_utf8_bom}"*) _current="${_current#"${_utf8_bom}"}" ;;
esac
slug_is_valid "${_current}" && printf '%s\n' "${_current}"
fi
return 0
}
list_plans() {
if [ ! -d "${PLAN_ROOT}" ]; then
printf '%s\n' 'No planning directory found.'
return 0
fi
if ! is_within_root "${PLAN_ROOT}"; then
printf '%s\n' 'Error: planning directory is outside the project or cannot be verified.' >&2
return 1
fi
_active="$(current_active)"
_found=0
printf '%s\n' 'Available plans:'
for _dir in "${PLAN_ROOT}"/*; do
[ -d "${_dir}" ] || continue
_id="${_dir##*/}"
slug_is_valid "${_id}" || continue
is_within_root "${_dir}" || continue
_plan_file="${_dir}/task_plan.md"
[ -f "${_plan_file}" ] && [ -r "${_plan_file}" ] || continue
is_within_root "${_plan_file}" || continue
_status="$(phase_status "${_plan_file}")" || continue
_marker=''
if [ "${_id}" = "${_active}" ]; then _marker=' [active]'; fi
printf '%s\n' "- ${_id}${_marker} - ${_status}"
_found=1
done
if [ "${_found}" -eq 0 ]; then
printf '%s\n' 'No named plans found.'
else
printf '%s\n' '[active] marks the shared default pointer. Set PLAN_ID to pin a session.'
fi
}
if [ "$#" -gt 1 ]; then
printf '%s\n' 'Error: list plans or set PLAN_ID in separate calls.' >&2
exit 1
fi
case "${1:-}" in
--list|-l) list_plans; exit $? ;;
--help|-h)
printf '%s\n' 'Usage: set-active-plan.sh [--list|PLAN_ID]' \
'Lists saved named plans in the current project without selecting a plan.'
exit 0 ;;
esac
if [ "${1:-}" = '' ]; then
if [ -d "${PLAN_ROOT}" ] && ! is_within_root "${PLAN_ROOT}"; then
printf '%s\n' 'Error: planning directory is outside the project or cannot be verified.' >&2
exit 1
fi
plan_id="$(current_active)"
if [ -n "${plan_id}" ] && [ -d "${PLAN_ROOT}/${plan_id}" ] && is_within_root "${PLAN_ROOT}/${plan_id}"; then
printf '%s\n' "Active plan: ${plan_id}" "Path: ${PLAN_ROOT}/${plan_id}"
elif [ -n "${plan_id}" ]; then
printf '%s\n' "Active plan pointer: ${plan_id} (directory not found or outside project - stale pointer)"
else
printf '%s\n' 'No active plan set.'
fi
exit 0
fi
PLAN_ID="$1"
PLAN_DIR="${PLAN_ROOT}/${PLAN_ID}"
if [ ! -d "${PLAN_DIR}" ]; then
echo "Error: plan directory not found: ${PLAN_DIR}" >&2
echo "Run: init-session.sh \"${PLAN_ID}\" to create it, or check .planning/ for available plans." >&2
if ! slug_is_valid "${PLAN_ID}"; then
printf '%s\n' 'Error: invalid plan ID. Use a named directory under .planning.' >&2
exit 1
fi
mkdir -p "${PLAN_ROOT}"
printf "%s\n" "${PLAN_ID}" > "${ACTIVE_FILE}"
echo "Active plan set to: ${PLAN_ID}"
echo "Path: ${PLAN_DIR}"
echo ""
echo "To pin this terminal session only:"
echo " export PLAN_ID=${PLAN_ID}"
PLAN_DIR="${PLAN_ROOT}/${PLAN_ID}"
if [ ! -d "${PLAN_DIR}" ]; then
printf '%s\n' "Error: plan directory not found: ${PLAN_DIR}" \
"Run: init-session.sh \"${PLAN_ID}\" to create it, or use --list to see available plans." >&2
exit 1
fi
if ! is_within_root "${PLAN_ROOT}" || ! is_within_root "${PLAN_DIR}"; then
printf '%s\n' 'Error: plan directory is outside the project or cannot be verified.' >&2
exit 1
fi
# A pre-existing pointer must itself be a contained regular file before writing.
if { [ -e "${ACTIVE_FILE}" ] || [ -L "${ACTIVE_FILE}" ]; } &&
{ [ -L "${ACTIVE_FILE}" ] || [ ! -f "${ACTIVE_FILE}" ] || ! is_within_root "${ACTIVE_FILE}"; }; then
printf '%s\n' 'Error: active plan pointer is not a safe file inside the project.' >&2
exit 1
fi
# Replace the pointer atomically instead of truncating a possible hardlink.
# mktemp creates a private, exclusive file beside the destination.
temp_file="$(mktemp "${PLAN_ROOT}/.active_plan.XXXXXX")" || {
printf '%s\n' 'Error: could not create the active plan pointer.' >&2
exit 1
}
trap 'rm -f "${temp_file}"' EXIT
trap 'exit 1' HUP INT TERM
printf '%s\n' "${PLAN_ID}" > "${temp_file}"
if ! mv -f "${temp_file}" "${ACTIVE_FILE}"; then
printf '%s\n' 'Error: could not replace the active plan pointer.' >&2
exit 1
fi
trap - EXIT HUP INT TERM
printf '%s\n' "Active plan set to: ${PLAN_ID}" "Path: ${PLAN_DIR}" '' \
'To pin this terminal session only:' " export PLAN_ID=${PLAN_ID}"
+7 -1
View File
@@ -37,7 +37,7 @@ hooks:
command: "SH=\"\"; for c in \"${PWF_SCRIPT_DIR}/skill-hook.sh\" \"${CLAUDE_SKILL_DIR}/scripts/skill-hook.sh\" \"$HOME/.claude/skills/planning-with-files-zh/scripts/skill-hook.sh\" \"$HOME/.claude/skills/planning-with-files/scripts/skill-hook.sh\" \"$HOME/.claude/plugins/marketplaces/planning-with-files/scripts/skill-hook.sh\"; do [ -f \"$c\" ] && { SH=\"$c\"; break; }; done; [ -n \"$SH\" ] && sh \"$SH\" --event=precompact; exit 0"
metadata:
version: "3.17.2"
version: "3.18.2"
---
@@ -233,6 +233,12 @@ if 操作失败:
- `scripts/check-complete.sh` — 验证所有阶段是否完成
- `scripts/session-catchup.py`:显式查看同项目会话元数据或有限摘录;无参数运行不会访问会话存储
### 列出已保存的计划
恢复任务前,可运行 `sh "<skill-dir>/scripts/set-active-plan.sh" --list` 查找计划;在 Windows PowerShell 中运行 `& "<skill-dir>/scripts/set-active-plan.ps1" -List`。将 `<skill-dir>` 替换为此技能的安装目录,并将当前工作目录保持在项目根目录。
此命令仅执行读取,列出当前目录下 `.planning/` 中的命名计划及阶段进度。`[active]` 表示共享的默认指针,不会将会话绑定到计划。并行任务仍需为每个宿主设置 `PLAN_ID`,或使用独立的工作树。
## 安全边界
此技能使用 PreToolUse 钩子在每次工具调用前重新读取 `task_plan.md`。写入 `task_plan.md` 的内容会被反复注入上下文,使其成为间接提示注入的高价值目标。
+266 -16
View File
@@ -1,27 +1,245 @@
# planning-with-files: set or display the active plan pointer (PowerShell).
# planning-with-files: set, display, or list the active plan pointer (PowerShell).
#
# Usage:
# .\set-active-plan.ps1 <plan_id> - pin .planning\.active_plan to plan_id
# .\set-active-plan.ps1 - print the current active plan (if any)
# .\set-active-plan.ps1 -List - list available named plans and phase counts
# .\set-active-plan.ps1 --list - equivalent to -List and -l
param(
[string]$PlanId = ""
[Parameter(Position = 0)]
[string]$PlanId = "",
[Alias('l', '-list')]
[switch]$List,
[Alias('h', '-help')]
[switch]$Help
)
$PlanRoot = Join-Path (Get-Location) ".planning"
$ProjectRoot = (Get-Location).Path
# Windows PowerShell 5.1 can silently change directories when -File inherits
# a cwd containing wildcard characters. Recover that physical cwd only for a
# direct invocation of this script; interactive Set-Location remains primary.
if ($PSVersionTable.PSVersion.Major -eq 5 -and
[System.Management.Automation.WildcardPattern]::ContainsWildcardCharacters([Environment]::CurrentDirectory)) {
$processArgs = [Environment]::GetCommandLineArgs()
for ($index = 0; $index -lt ($processArgs.Length - 1); $index++) {
if ($processArgs[$index] -ieq '-File') {
$entryScript = $processArgs[$index + 1]
if (-not [IO.Path]::IsPathRooted($entryScript)) {
$entryScript = Join-Path ([Environment]::CurrentDirectory) $entryScript
}
if ([IO.Path]::GetFullPath($entryScript) -eq [IO.Path]::GetFullPath($PSCommandPath)) {
$ProjectRoot = [Environment]::CurrentDirectory
}
break
}
}
}
$PlanRoot = Join-Path $ProjectRoot ".planning"
$ActiveFile = Join-Path $PlanRoot ".active_plan"
$script:IsWindowsHost = [Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT
# Resolve-Path can retain a junction's spelling. Like resolve-plan-dir.ps1,
# open the actual filesystem object before checking the project boundary.
# The Windows handle supports both directories and task_plan.md files.
function Get-FinalPath {
param([string]$Path)
if (-not ("PwfPlanListingNative" -as [type])) {
Add-Type -ErrorAction Stop -TypeDefinition @'
using System;
using System.ComponentModel;
using System.Runtime.InteropServices;
using System.Text;
using Microsoft.Win32.SafeHandles;
public static class PwfPlanListingNative {
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern SafeFileHandle CreateFileW(
string name, uint access, uint share, IntPtr security,
uint creation, uint flags, IntPtr template);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern uint GetFinalPathNameByHandleW(
SafeFileHandle handle, StringBuilder path, uint length, uint flags);
[DllImport("libc", SetLastError = true)]
private static extern IntPtr realpath(string path, IntPtr resolved);
[DllImport("libc")]
private static extern void free(IntPtr pointer);
public static string FinalPath(string path) {
if (Environment.OSVersion.Platform != PlatformID.Win32NT) {
IntPtr resolved = realpath(path, IntPtr.Zero);
if (resolved == IntPtr.Zero) throw new Win32Exception(Marshal.GetLastWin32Error());
try { return Marshal.PtrToStringAnsi(resolved); }
finally { free(resolved); }
}
// OPEN_EXISTING, FILE_SHARE_READ | WRITE | DELETE, BACKUP_SEMANTICS.
using (SafeFileHandle handle = CreateFileW(
path, 0, 7, IntPtr.Zero, 3, 0x02000000, IntPtr.Zero)) {
if (handle.IsInvalid) throw new Win32Exception(Marshal.GetLastWin32Error());
StringBuilder buffer = new StringBuilder(32768);
uint length = GetFinalPathNameByHandleW(handle, buffer, (uint)buffer.Capacity, 0);
if (length == 0 || length >= buffer.Capacity)
throw new Win32Exception(Marshal.GetLastWin32Error());
string result = buffer.ToString();
if (result.StartsWith(@"\\?\UNC\", StringComparison.OrdinalIgnoreCase))
return @"\\" + result.Substring(8);
if (result.StartsWith(@"\\?\", StringComparison.OrdinalIgnoreCase))
return result.Substring(4);
return result;
}
}
}
'@
}
return [PwfPlanListingNative]::FinalPath($Path)
}
function Test-WithinRoot {
param([string]$Path)
try {
$rootReal = (Get-FinalPath $ProjectRoot).TrimEnd('\', '/')
$pathReal = (Get-FinalPath $Path).TrimEnd('\', '/')
$comparison = [StringComparison]::Ordinal
if ($script:IsWindowsHost) { $comparison = [StringComparison]::OrdinalIgnoreCase }
return $pathReal.Equals($rootReal, $comparison) -or
$pathReal.StartsWith($rootReal + [IO.Path]::DirectorySeparatorChar, $comparison)
} catch {
return $false
}
}
function Test-ValidSlug {
param([string]$Name)
return $Name -cmatch '^[A-Za-z0-9_][A-Za-z0-9._-]*\z'
}
function Test-SafeActiveFile {
param([switch]$AllowLink)
$item = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
if (-not $item) { return $false }
# Reading may follow a verified in-project link; writing must not.
return -not $item.PSIsContainer -and
($AllowLink -or (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0)) -and
(Test-WithinRoot $ActiveFile)
}
function Get-CurrentActivePlan {
if (-not (Test-SafeActiveFile -AllowLink)) { return "" }
try {
$current = ([string](Get-Content -LiteralPath $ActiveFile -Raw -Encoding UTF8 -ErrorAction Stop)).Trim([char[]]"`r`n")
if (Test-ValidSlug $current) { return $current }
} catch { }
return ""
}
function Add-PhaseStatus {
param([hashtable]$Counts, $Phase)
if ($null -eq $Phase) { return }
$status = $Phase.Inline
if ($Phase.Primary) { $status = $Phase.Primary }
if ($status) { $Counts[$status]++ }
}
function Get-PhaseStatus {
param([string]$PlanFile)
$counts = @{ total = 0; complete = 0; in_progress = 0; pending = 0 }
$phase = $null
$fence = ""
$fenceLength = 0
# The shipped translations keep status identifiers in English. Unicode
# escapes preserve Windows PowerShell 5.1 compatibility without a BOM.
$phaseLabel = '(?:Phase|Fase|\u0627\u0644\u0645\u0631\u062d\u0644\u0629|\u9636\u6bb5|\u968e\u6bb5)'
$statusLabel = '(?:Status:|Estado:|\u0627\u0644\u062d\u0627\u0644\u0629:|\u72b6\u6001\uff1a|\u72c0\u614b\uff1a)'
# Read once; an empty file is a valid plan with zero phases.
$content = [string](Get-Content -LiteralPath $PlanFile -Raw -Encoding UTF8 -ErrorAction Stop)
foreach ($line in ($content -split '\r?\n')) {
if ($fence) {
$closing = '^ {0,3}' + [regex]::Escape($fence) + '{' + $fenceLength + ',}[ \t]*$'
if ($line -match $closing) { $fence = "" }
continue
}
if ($line -match '^ {0,3}(`{3,}|~{3,})') {
$fence = $Matches[1].Substring(0, 1)
$fenceLength = $Matches[1].Length
continue
}
if ($line -cmatch ('^ {0,3}###[ \t]+' + $phaseLabel + '[ \t]+[0-9]+(?:[^0-9A-Za-z_]|$)')) {
Add-PhaseStatus $counts $phase
$phase = @{ Primary = ""; Inline = "" }
$counts.total++
if ($line -cmatch '\[(complete|in_progress|pending)\]') {
$phase.Inline = $Matches[1]
}
continue
}
if ($line -match '^ {0,3}#{1,3}(?:[ \t]+|$)') {
Add-PhaseStatus $counts $phase
$phase = $null
continue
}
if ($null -ne $phase -and -not $phase.Primary -and
$line -cmatch ('^ {0,3}(?:-[ \t]+)?\*\*' + $statusLabel + '\*\*[ \t]+(complete|in_progress|pending)(?:[ \t]|$)')) {
$phase.Primary = $Matches[1]
}
}
Add-PhaseStatus $counts $phase
return "$($counts.complete)/$($counts.total) complete, $($counts.in_progress) in_progress, $($counts.pending) pending"
}
function Show-PlanList {
if (-not (Test-Path -LiteralPath $PlanRoot -PathType Container)) {
Write-Output "No planning directory found."
return
}
if (-not (Test-WithinRoot $PlanRoot)) {
Write-Error "Error: planning directory is outside the project or cannot be verified."
exit 1
}
$active = Get-CurrentActivePlan
Write-Output "Available plans:"
Write-Output "[active] marks the shared .active_plan pointer; listing does not bind this session."
$found = $false
foreach ($plan in (Get-ChildItem -LiteralPath $PlanRoot -Directory -ErrorAction SilentlyContinue | Sort-Object Name)) {
if (-not (Test-ValidSlug $plan.Name) -or -not (Test-WithinRoot $plan.FullName)) { continue }
$planFile = Join-Path $plan.FullName "task_plan.md"
if (-not (Test-Path -LiteralPath $planFile -PathType Leaf) -or -not (Test-WithinRoot $planFile)) { continue }
try { $status = Get-PhaseStatus $planFile } catch { continue }
$marker = ""
if ($plan.Name -ceq $active) { $marker = " [active]" }
Write-Output "- $($plan.Name)$marker - $status"
$found = $true
}
if (-not $found) { Write-Output "No named plans found." }
}
if ($Help -or $PlanId -eq "--help" -or $PlanId -eq "-h") {
Write-Output "Usage: set-active-plan.ps1 [-List|-l|--list|PLAN_ID]"
exit 0
}
if ($List -or $PlanId -eq "--list" -or $PlanId -eq "-l") {
if ($List -and $PlanId) {
Write-Error "Error: list plans or set PLAN_ID in separate calls."
exit 1
}
Show-PlanList
exit 0
}
if ($PlanId -eq "") {
if (Test-Path $ActiveFile) {
$current = (Get-Content $ActiveFile -Raw -Encoding UTF8).Trim()
$current = ""
if ((Test-Path -LiteralPath $PlanRoot -PathType Container) -and -not (Test-WithinRoot $PlanRoot)) {
Write-Error "Error: planning directory is outside the project or cannot be verified."
exit 1
}
if (Test-WithinRoot $PlanRoot) { $current = Get-CurrentActivePlan }
if ($current) {
$planDir = Join-Path $PlanRoot $current
if ($current -ne "" -and (Test-Path $planDir)) {
if ((Test-Path -LiteralPath $planDir -PathType Container) -and (Test-WithinRoot $planDir)) {
Write-Output "Active plan: $current"
Write-Output "Path: $planDir"
} elseif ($current -ne "") {
Write-Output "Active plan pointer: $current (directory not found - stale pointer)"
} else {
Write-Output "No active plan set."
Write-Output "Active plan pointer: $current (directory not found or outside project - stale pointer)"
}
} else {
Write-Output "No active plan set."
@@ -29,21 +247,53 @@ if ($PlanId -eq "") {
exit 0
}
if (-not (Test-ValidSlug $PlanId)) {
Write-Error "Error: invalid plan ID. Use letters, numbers, underscores, dots, or hyphens; start with a letter, number, or underscore."
exit 1
}
$PlanDir = Join-Path $PlanRoot $PlanId
if (-not (Test-Path $PlanDir)) {
if (-not (Test-Path -LiteralPath $PlanDir -PathType Container)) {
Write-Error "Error: plan directory not found: $PlanDir"
Write-Error "Run: init-session.sh `"$PlanId`" to create it, or check .planning\ for available plans."
exit 1
}
if (-not (Test-Path $PlanRoot)) {
New-Item -ItemType Directory -Path $PlanRoot -Force | Out-Null
if (-not (Test-WithinRoot $PlanRoot) -or -not (Test-WithinRoot $PlanDir)) {
Write-Error "Error: plan directory must remain within the project."
exit 1
}
$activeItem = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
if ($activeItem -and -not (Test-SafeActiveFile)) {
Write-Error "Error: the active plan pointer must be a regular file within the project."
exit 1
}
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($ActiveFile, $PlanId, $utf8NoBom)
# Replace the directory entry instead of truncating an existing inode: a
# hardlinked pointer must not overwrite another file. The exclusive temporary
# file lives beside the pointer, keeping replacement on the same filesystem.
$tempFile = Join-Path $PlanRoot ('.active_plan.' + [guid]::NewGuid().ToString('N') + '.tmp')
$createdTemp = $false
try {
$stream = [IO.File]::Open($tempFile, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
$createdTemp = $true
try {
$bytes = $utf8NoBom.GetBytes($PlanId)
$stream.Write($bytes, 0, $bytes.Length)
$stream.Flush()
} finally { $stream.Dispose() }
if ($activeItem) {
[IO.File]::Replace($tempFile, $ActiveFile, [NullString]::Value)
} else {
[IO.File]::Move($tempFile, $ActiveFile)
}
} catch {
Write-Error "Error: could not set the active plan pointer: $($_.Exception.Message)"
exit 1
} finally {
if ($createdTemp -and (Test-Path -LiteralPath $tempFile)) {
Remove-Item -LiteralPath $tempFile -Force -ErrorAction SilentlyContinue
}
}
Write-Output "Active plan set to: $PlanId"
Write-Output "Path: $PlanDir"
Write-Output ""
+323 -38
View File
@@ -1,50 +1,335 @@
#!/bin/sh
# planning-with-files: set or display the active plan pointer.
#
# Usage:
# set-active-plan.sh <plan_id> — pin .planning/.active_plan to plan_id
# set-active-plan.sh — print the current active plan (if any)
#
# The active plan is stored in .planning/.active_plan and is read by
# resolve-plan-dir.sh when no $PLAN_ID env var is set.
set -e
# List saved named plans, show the shared pointer, or change that pointer.
# Usage: set-active-plan.sh [--list|-l|PLAN_ID]
# Operates on the current project; listing never binds a host or injects data.
set -eu
PLAN_ROOT="${PWD}/.planning"
ACTIVE_FILE="${PLAN_ROOT}/.active_plan"
PWF_ROOT_PIN=""
# No args → show current active plan
if [ "${1:-}" = "" ]; then
if [ -f "${ACTIVE_FILE}" ]; then
plan_id="$(tr -d '\r\n' < "${ACTIVE_FILE}")"
if [ -n "${plan_id}" ] && [ -d "${PLAN_ROOT}/${plan_id}" ]; then
echo "Active plan: ${plan_id}"
echo "Path: ${PLAN_ROOT}/${plan_id}"
elif [ -n "${plan_id}" ]; then
echo "Active plan pointer: ${plan_id} (directory not found — stale pointer)"
else
echo "No active plan set."
fi
# Use the resolver canonicalization policy without running plan selection.
slug_is_valid() {
case "$1" in
'') return 1 ;;
*[!A-Za-z0-9._-]*) return 1 ;;
[A-Za-z0-9_]*) return 0 ;;
esac
return 1
}
# Pure-sh backslash-to-forward-slash normalizer; result lands in $NORM_OUT.
# Windows-native coreutils builds (e.g. C:\Program Files\coreutils on PATH
# ahead of Git's usr/bin) canonicalize MSYS-style /c/... input to C:\-style
# backslash output. The containment prefix match below is written with forward
# slashes, so without this normalization every canonical pair mismatches and
# resolution silently fails. On POSIX systems paths contain no backslash and
# this is the identity. A literal backslash in a Unix filename normalizes to
# "/" and at worst fails containment — the safe direction. No subshell, no
# fork: plain parameter expansion in a loop.
norm_slashes() {
NORM_OUT=""
_ns_rest="$1"
while :; do
case "${_ns_rest}" in
*\\*)
NORM_OUT="${NORM_OUT}${_ns_rest%%\\*}/"
_ns_rest="${_ns_rest#*\\}"
;;
*)
NORM_OUT="${NORM_OUT}${_ns_rest}"
break
;;
esac
done
}
# Return true when a candidate path names the Microsoft Store WindowsApps
# directory. Store app aliases are not stable interpreter binaries and may
# present as executable while refusing script execution. Matching is
# case-insensitive and works before or after Windows slash normalization.
is_windowsapps_path() {
norm_slashes "$1"
case "${NORM_OUT}" in
[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]|\
[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]/*|\
*/[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]|\
*/[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]/*) return 0 ;;
esac
return 1
}
# Select only an interpreter path the caller explicitly trusted.
# PWF_TRUSTED_PYTHON is preferred; PYTHON_BIN remains a compatibility alias.
# PATH discovery is intentionally forbidden because resolver hooks can run in
# repositories that control PATH. Windows-native absolute paths are converted
# with Git Bash's fixed system cygpath, never a PATH-selected shim.
trusted_python() {
for _tp_candidate in "${PWF_TRUSTED_PYTHON:-}" "${PYTHON_BIN:-}"; do
[ -n "${_tp_candidate}" ] || continue
case "${_tp_candidate}" in
\\\\*|//*) continue ;;
[A-Za-z]:[\\/]*)
is_windowsapps_path "${_tp_candidate}" && continue
_tp_cygpath="/usr/bin/cygpath.exe"
[ -f "${_tp_cygpath}" ] && [ -x "${_tp_cygpath}" ] || continue
_tp_candidate="$("${_tp_cygpath}" -u "${_tp_candidate}" 2>/dev/null)" \
|| continue
;;
/*) ;;
*) continue ;;
esac
is_windowsapps_path "${_tp_candidate}" && continue
[ -f "${_tp_candidate}" ] || continue
[ -x "${_tp_candidate}" ] || continue
printf "%s\n" "${_tp_candidate}"
return 0
done
return 1
}
# Portable path canonicalizer. realpath first (Linux, modern coreutils),
# then readlink -f (older GNU), then an explicitly trusted Python interpreter.
# Prints the canonical absolute path on success; prints nothing and returns 1
# on a full miss so containment fails closed. No Python spawn on the happy
# path: realpath/readlink cover Linux, WSL, Git-Bash, and modern macOS.
canonicalize() {
target="$1"
if command -v realpath >/dev/null 2>&1; then
out="$(realpath "${target}" 2>/dev/null)" && [ -n "${out}" ] && {
printf "%s\n" "${out}"; return 0; }
fi
if command -v readlink >/dev/null 2>&1; then
out="$(readlink -f "${target}" 2>/dev/null)" && [ -n "${out}" ] && {
printf "%s\n" "${out}"; return 0; }
fi
_canonical_python="$(trusted_python)" || _canonical_python=""
if [ -n "${_canonical_python}" ]; then
out="$("${_canonical_python}" -I -c "import os,sys;print(os.path.realpath(sys.argv[1]))" "${target}" 2>/dev/null)" \
&& [ -n "${out}" ] && { printf "%s\n" "${out}"; return 0; }
fi
return 1
}
# Containment guard (security A1.3): a resolved plan dir must canonicalize to a
# path under the project root (the CWD the script runs from). A symlink inside
# a valid slug dir pointing at /etc or outside the workspace would otherwise let
# the hooks hash and inject an arbitrary file. On any violation we return 1 so
# the caller treats the candidate as unresolved and falls back safely.
#
# The root canonicalizes via the relative token "." rather than the $PWD
# string. On some Windows/MSYS setups (8.3 short names, the /tmp mount alias)
# realpath("$PWD") and realpath(relative-candidate) resolve through different
# code paths and land on differently-spelled-but-equal targets, so the prefix
# match below fails and resolution silently goes dark. "." resolves through
# the same physical-cwd path candidates already use (same fix inject-plan.sh
# received earlier; the resolver kept the $PWD form until now). Both sides are
# backslash-normalized before comparison for Windows-native canonicalizers.
# The root is computed once per run: the newest-mtime scan calls this guard
# per plan dir, and each canonicalize costs a process spawn on Windows.
#
# With a PWF_PLAN_ROOT pin (issue #212) containment is checked against THAT
# root instead of the cwd: candidates arrive ${PWF_PLAN_ROOT}/-prefixed, so
# both sides canonicalize through the same path spelling. Unpinned keeps the
# relative "." root — byte-identical to the legacy check.
ROOT_REAL=""
ROOT_REAL_SET=0
is_within_root() {
candidate="$1"
if [ "${ROOT_REAL_SET}" = "0" ]; then
ROOT_REAL="$(canonicalize "${PWF_ROOT_PIN:-.}")" || ROOT_REAL=""
norm_slashes "${ROOT_REAL}"
ROOT_REAL="${NORM_OUT}"
ROOT_REAL_SET=1
fi
# Canonicalize the candidate through its cwd-RELATIVE form whenever it
# lives under ${PWD}. The candidate string is built from ${PWD} (an MSYS
# long-form spelling), while the root canonicalizes from "." (the process
# cwd, which a caller may have set with an 8.3 short-form string). A
# Windows-native realpath does not unify those spellings, so canonicalizing
# both sides from the same cwd base is the only spelling-stable comparison.
# The emitted result keeps the original absolute candidate — only the
# containment check uses the relative form.
# Pinned resolution skips the rewrite: candidate and root then share the
# ${PWF_PLAN_ROOT} spelling, so both canonicalize directly from it.
if [ -n "${PWF_ROOT_PIN}" ]; then
check_target="${candidate}"
else
echo "No active plan set."
case "${candidate}" in
"${PWD}"/*) check_target=".${candidate#"${PWD}"}" ;;
*) check_target="${candidate}" ;;
esac
fi
cand_real="$(canonicalize "${check_target}")" || cand_real=""
norm_slashes "${cand_real}"
cand_real="${NORM_OUT}"
if [ -z "${ROOT_REAL}" ] || [ -z "${cand_real}" ]; then
# Slug validation blocks textual traversal, but only successful
# canonicalization can rule out a symlink/junction escape.
return 1
fi
case "${cand_real}" in
"${ROOT_REAL}"|"${ROOT_REAL}"/*) return 0 ;;
*) return 1 ;;
esac
}
# Each phase contributes at most one status. An explicit Status line wins
# over an inline heading marker. Fenced examples and unrelated sections are
# not phases. Keep this parser aligned with the PowerShell helper.
phase_status() {
awk '
function finish() {
if (phase) {
status = primary != "" ? primary : inline_status
if (status == "complete") complete++
else if (status == "in_progress") in_progress++
else if (status == "pending") pending++
}
phase = 0; primary = ""; inline_status = ""
}
{
line = $0
sub(/\r$/, "", line)
trimmed = line
sub(/^ */, "", trimmed)
if (line ~ /^ ? ? ?```/ || line ~ /^ ? ? ?~~~/) {
marker = substr(trimmed, 1, 1)
run = 0
while (substr(trimmed, run + 1, 1) == marker) run++
if (fence == "") { fence = marker; fence_length = run }
else if (marker == fence && run >= fence_length && substr(trimmed, run + 1) ~ /^[ \t]*$/) fence = ""
next
}
if (fence != "") next
if (line ~ /^ ? ? ?###[ \t]+(Phase|Fase|المرحلة|阶段|階段)[ \t]+[0-9]+([^0-9A-Za-z_]|$)/) {
finish(); phase = 1; total++
if (match(line, /\[(complete|in_progress|pending)\]/))
inline_status = substr(line, RSTART + 1, RLENGTH - 2)
} else if (line ~ /^ ? ? ?(###|##|#)([ \t]|$)/) {
finish()
} else if (phase && primary == "" && line ~ /^ ? ? ?(-[ \t]+)?\*\*(Status:|Estado:|الحالة:|状态:|狀態:)\*\*[ \t]+(complete|in_progress|pending)([ \t]|$)/) {
sub(/^ ? ? ?(-[ \t]+)?\*\*(Status:|Estado:|الحالة:|状态:|狀態:)\*\*[ \t]+/, "", line)
sub(/[ \t].*$/, "", line)
primary = line
}
}
END {
finish()
printf "%d/%d complete, %d in_progress, %d pending", complete, total, in_progress, pending
}
' < "$1"
}
current_active() {
if [ -f "${ACTIVE_FILE}" ] && is_within_root "${ACTIVE_FILE}"; then
_current="$(tr '\r' '\n' < "${ACTIVE_FILE}")"
# Windows editors and older PowerShell defaults can leave a UTF-8 BOM.
# Treat it as an encoding marker, not part of the shared plan slug.
_utf8_bom="$(printf '\357\273\277')"
case "${_current}" in
"${_utf8_bom}"*) _current="${_current#"${_utf8_bom}"}" ;;
esac
slug_is_valid "${_current}" && printf '%s\n' "${_current}"
fi
return 0
}
list_plans() {
if [ ! -d "${PLAN_ROOT}" ]; then
printf '%s\n' 'No planning directory found.'
return 0
fi
if ! is_within_root "${PLAN_ROOT}"; then
printf '%s\n' 'Error: planning directory is outside the project or cannot be verified.' >&2
return 1
fi
_active="$(current_active)"
_found=0
printf '%s\n' 'Available plans:'
for _dir in "${PLAN_ROOT}"/*; do
[ -d "${_dir}" ] || continue
_id="${_dir##*/}"
slug_is_valid "${_id}" || continue
is_within_root "${_dir}" || continue
_plan_file="${_dir}/task_plan.md"
[ -f "${_plan_file}" ] && [ -r "${_plan_file}" ] || continue
is_within_root "${_plan_file}" || continue
_status="$(phase_status "${_plan_file}")" || continue
_marker=''
if [ "${_id}" = "${_active}" ]; then _marker=' [active]'; fi
printf '%s\n' "- ${_id}${_marker} - ${_status}"
_found=1
done
if [ "${_found}" -eq 0 ]; then
printf '%s\n' 'No named plans found.'
else
printf '%s\n' '[active] marks the shared default pointer. Set PLAN_ID to pin a session.'
fi
}
if [ "$#" -gt 1 ]; then
printf '%s\n' 'Error: list plans or set PLAN_ID in separate calls.' >&2
exit 1
fi
case "${1:-}" in
--list|-l) list_plans; exit $? ;;
--help|-h)
printf '%s\n' 'Usage: set-active-plan.sh [--list|PLAN_ID]' \
'Lists saved named plans in the current project without selecting a plan.'
exit 0 ;;
esac
if [ "${1:-}" = '' ]; then
if [ -d "${PLAN_ROOT}" ] && ! is_within_root "${PLAN_ROOT}"; then
printf '%s\n' 'Error: planning directory is outside the project or cannot be verified.' >&2
exit 1
fi
plan_id="$(current_active)"
if [ -n "${plan_id}" ] && [ -d "${PLAN_ROOT}/${plan_id}" ] && is_within_root "${PLAN_ROOT}/${plan_id}"; then
printf '%s\n' "Active plan: ${plan_id}" "Path: ${PLAN_ROOT}/${plan_id}"
elif [ -n "${plan_id}" ]; then
printf '%s\n' "Active plan pointer: ${plan_id} (directory not found or outside project - stale pointer)"
else
printf '%s\n' 'No active plan set.'
fi
exit 0
fi
PLAN_ID="$1"
PLAN_DIR="${PLAN_ROOT}/${PLAN_ID}"
if [ ! -d "${PLAN_DIR}" ]; then
echo "Error: plan directory not found: ${PLAN_DIR}" >&2
echo "Run: init-session.sh \"${PLAN_ID}\" to create it, or check .planning/ for available plans." >&2
if ! slug_is_valid "${PLAN_ID}"; then
printf '%s\n' 'Error: invalid plan ID. Use a named directory under .planning.' >&2
exit 1
fi
mkdir -p "${PLAN_ROOT}"
printf "%s\n" "${PLAN_ID}" > "${ACTIVE_FILE}"
echo "Active plan set to: ${PLAN_ID}"
echo "Path: ${PLAN_DIR}"
echo ""
echo "To pin this terminal session only:"
echo " export PLAN_ID=${PLAN_ID}"
PLAN_DIR="${PLAN_ROOT}/${PLAN_ID}"
if [ ! -d "${PLAN_DIR}" ]; then
printf '%s\n' "Error: plan directory not found: ${PLAN_DIR}" \
"Run: init-session.sh \"${PLAN_ID}\" to create it, or use --list to see available plans." >&2
exit 1
fi
if ! is_within_root "${PLAN_ROOT}" || ! is_within_root "${PLAN_DIR}"; then
printf '%s\n' 'Error: plan directory is outside the project or cannot be verified.' >&2
exit 1
fi
# A pre-existing pointer must itself be a contained regular file before writing.
if { [ -e "${ACTIVE_FILE}" ] || [ -L "${ACTIVE_FILE}" ]; } &&
{ [ -L "${ACTIVE_FILE}" ] || [ ! -f "${ACTIVE_FILE}" ] || ! is_within_root "${ACTIVE_FILE}"; }; then
printf '%s\n' 'Error: active plan pointer is not a safe file inside the project.' >&2
exit 1
fi
# Replace the pointer atomically instead of truncating a possible hardlink.
# mktemp creates a private, exclusive file beside the destination.
temp_file="$(mktemp "${PLAN_ROOT}/.active_plan.XXXXXX")" || {
printf '%s\n' 'Error: could not create the active plan pointer.' >&2
exit 1
}
trap 'rm -f "${temp_file}"' EXIT
trap 'exit 1' HUP INT TERM
printf '%s\n' "${PLAN_ID}" > "${temp_file}"
if ! mv -f "${temp_file}" "${ACTIVE_FILE}"; then
printf '%s\n' 'Error: could not replace the active plan pointer.' >&2
exit 1
fi
trap - EXIT HUP INT TERM
printf '%s\n' "Active plan set to: ${PLAN_ID}" "Path: ${PLAN_DIR}" '' \
'To pin this terminal session only:' " export PLAN_ID=${PLAN_ID}"
+7 -1
View File
@@ -37,7 +37,7 @@ hooks:
command: "SH=\"\"; for c in \"${PWF_SCRIPT_DIR}/skill-hook.sh\" \"${CLAUDE_SKILL_DIR}/scripts/skill-hook.sh\" \"$HOME/.claude/skills/planning-with-files-zht/scripts/skill-hook.sh\" \"$HOME/.claude/skills/planning-with-files/scripts/skill-hook.sh\" \"$HOME/.claude/plugins/marketplaces/planning-with-files/scripts/skill-hook.sh\"; do [ -f \"$c\" ] && { SH=\"$c\"; break; }; done; [ -n \"$SH\" ] && sh \"$SH\" --event=precompact; exit 0"
metadata:
version: "3.17.2"
version: "3.18.2"
---
@@ -233,6 +233,12 @@ if 操作失敗:
- `scripts/check-complete.sh` — 驗證所有階段是否完成
- `scripts/session-catchup.py`:依明確選擇輸出本機同一專案的中繼資料或有界重播內容
### 列出已儲存的計畫
恢復任務前,可執行 `sh "<skill-dir>/scripts/set-active-plan.sh" --list` 尋找計畫;在 Windows PowerShell 中執行 `& "<skill-dir>/scripts/set-active-plan.ps1" -List`。將 `<skill-dir>` 替換為此技能的安裝目錄,並將目前工作目錄保持在專案根目錄。
此命令僅執行讀取,列出目前目錄下 `.planning/` 中的具名計畫及階段進度。`[active]` 表示共用的預設指標,不會將工作階段綁定至計畫。平行任務仍需為每個宿主設定 `PLAN_ID`,或使用獨立的工作樹。
## 安全邊界
此技能使用 PreToolUse 鉤子在每次工具呼叫前重新讀取 `task_plan.md`。寫入 `task_plan.md` 的內容會被反覆注入上下文,使其成為間接提示注入的高價值目標。
+266 -16
View File
@@ -1,27 +1,245 @@
# planning-with-files: set or display the active plan pointer (PowerShell).
# planning-with-files: set, display, or list the active plan pointer (PowerShell).
#
# Usage:
# .\set-active-plan.ps1 <plan_id> - pin .planning\.active_plan to plan_id
# .\set-active-plan.ps1 - print the current active plan (if any)
# .\set-active-plan.ps1 -List - list available named plans and phase counts
# .\set-active-plan.ps1 --list - equivalent to -List and -l
param(
[string]$PlanId = ""
[Parameter(Position = 0)]
[string]$PlanId = "",
[Alias('l', '-list')]
[switch]$List,
[Alias('h', '-help')]
[switch]$Help
)
$PlanRoot = Join-Path (Get-Location) ".planning"
$ProjectRoot = (Get-Location).Path
# Windows PowerShell 5.1 can silently change directories when -File inherits
# a cwd containing wildcard characters. Recover that physical cwd only for a
# direct invocation of this script; interactive Set-Location remains primary.
if ($PSVersionTable.PSVersion.Major -eq 5 -and
[System.Management.Automation.WildcardPattern]::ContainsWildcardCharacters([Environment]::CurrentDirectory)) {
$processArgs = [Environment]::GetCommandLineArgs()
for ($index = 0; $index -lt ($processArgs.Length - 1); $index++) {
if ($processArgs[$index] -ieq '-File') {
$entryScript = $processArgs[$index + 1]
if (-not [IO.Path]::IsPathRooted($entryScript)) {
$entryScript = Join-Path ([Environment]::CurrentDirectory) $entryScript
}
if ([IO.Path]::GetFullPath($entryScript) -eq [IO.Path]::GetFullPath($PSCommandPath)) {
$ProjectRoot = [Environment]::CurrentDirectory
}
break
}
}
}
$PlanRoot = Join-Path $ProjectRoot ".planning"
$ActiveFile = Join-Path $PlanRoot ".active_plan"
$script:IsWindowsHost = [Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT
# Resolve-Path can retain a junction's spelling. Like resolve-plan-dir.ps1,
# open the actual filesystem object before checking the project boundary.
# The Windows handle supports both directories and task_plan.md files.
function Get-FinalPath {
param([string]$Path)
if (-not ("PwfPlanListingNative" -as [type])) {
Add-Type -ErrorAction Stop -TypeDefinition @'
using System;
using System.ComponentModel;
using System.Runtime.InteropServices;
using System.Text;
using Microsoft.Win32.SafeHandles;
public static class PwfPlanListingNative {
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern SafeFileHandle CreateFileW(
string name, uint access, uint share, IntPtr security,
uint creation, uint flags, IntPtr template);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern uint GetFinalPathNameByHandleW(
SafeFileHandle handle, StringBuilder path, uint length, uint flags);
[DllImport("libc", SetLastError = true)]
private static extern IntPtr realpath(string path, IntPtr resolved);
[DllImport("libc")]
private static extern void free(IntPtr pointer);
public static string FinalPath(string path) {
if (Environment.OSVersion.Platform != PlatformID.Win32NT) {
IntPtr resolved = realpath(path, IntPtr.Zero);
if (resolved == IntPtr.Zero) throw new Win32Exception(Marshal.GetLastWin32Error());
try { return Marshal.PtrToStringAnsi(resolved); }
finally { free(resolved); }
}
// OPEN_EXISTING, FILE_SHARE_READ | WRITE | DELETE, BACKUP_SEMANTICS.
using (SafeFileHandle handle = CreateFileW(
path, 0, 7, IntPtr.Zero, 3, 0x02000000, IntPtr.Zero)) {
if (handle.IsInvalid) throw new Win32Exception(Marshal.GetLastWin32Error());
StringBuilder buffer = new StringBuilder(32768);
uint length = GetFinalPathNameByHandleW(handle, buffer, (uint)buffer.Capacity, 0);
if (length == 0 || length >= buffer.Capacity)
throw new Win32Exception(Marshal.GetLastWin32Error());
string result = buffer.ToString();
if (result.StartsWith(@"\\?\UNC\", StringComparison.OrdinalIgnoreCase))
return @"\\" + result.Substring(8);
if (result.StartsWith(@"\\?\", StringComparison.OrdinalIgnoreCase))
return result.Substring(4);
return result;
}
}
}
'@
}
return [PwfPlanListingNative]::FinalPath($Path)
}
function Test-WithinRoot {
param([string]$Path)
try {
$rootReal = (Get-FinalPath $ProjectRoot).TrimEnd('\', '/')
$pathReal = (Get-FinalPath $Path).TrimEnd('\', '/')
$comparison = [StringComparison]::Ordinal
if ($script:IsWindowsHost) { $comparison = [StringComparison]::OrdinalIgnoreCase }
return $pathReal.Equals($rootReal, $comparison) -or
$pathReal.StartsWith($rootReal + [IO.Path]::DirectorySeparatorChar, $comparison)
} catch {
return $false
}
}
function Test-ValidSlug {
param([string]$Name)
return $Name -cmatch '^[A-Za-z0-9_][A-Za-z0-9._-]*\z'
}
function Test-SafeActiveFile {
param([switch]$AllowLink)
$item = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
if (-not $item) { return $false }
# Reading may follow a verified in-project link; writing must not.
return -not $item.PSIsContainer -and
($AllowLink -or (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0)) -and
(Test-WithinRoot $ActiveFile)
}
function Get-CurrentActivePlan {
if (-not (Test-SafeActiveFile -AllowLink)) { return "" }
try {
$current = ([string](Get-Content -LiteralPath $ActiveFile -Raw -Encoding UTF8 -ErrorAction Stop)).Trim([char[]]"`r`n")
if (Test-ValidSlug $current) { return $current }
} catch { }
return ""
}
function Add-PhaseStatus {
param([hashtable]$Counts, $Phase)
if ($null -eq $Phase) { return }
$status = $Phase.Inline
if ($Phase.Primary) { $status = $Phase.Primary }
if ($status) { $Counts[$status]++ }
}
function Get-PhaseStatus {
param([string]$PlanFile)
$counts = @{ total = 0; complete = 0; in_progress = 0; pending = 0 }
$phase = $null
$fence = ""
$fenceLength = 0
# The shipped translations keep status identifiers in English. Unicode
# escapes preserve Windows PowerShell 5.1 compatibility without a BOM.
$phaseLabel = '(?:Phase|Fase|\u0627\u0644\u0645\u0631\u062d\u0644\u0629|\u9636\u6bb5|\u968e\u6bb5)'
$statusLabel = '(?:Status:|Estado:|\u0627\u0644\u062d\u0627\u0644\u0629:|\u72b6\u6001\uff1a|\u72c0\u614b\uff1a)'
# Read once; an empty file is a valid plan with zero phases.
$content = [string](Get-Content -LiteralPath $PlanFile -Raw -Encoding UTF8 -ErrorAction Stop)
foreach ($line in ($content -split '\r?\n')) {
if ($fence) {
$closing = '^ {0,3}' + [regex]::Escape($fence) + '{' + $fenceLength + ',}[ \t]*$'
if ($line -match $closing) { $fence = "" }
continue
}
if ($line -match '^ {0,3}(`{3,}|~{3,})') {
$fence = $Matches[1].Substring(0, 1)
$fenceLength = $Matches[1].Length
continue
}
if ($line -cmatch ('^ {0,3}###[ \t]+' + $phaseLabel + '[ \t]+[0-9]+(?:[^0-9A-Za-z_]|$)')) {
Add-PhaseStatus $counts $phase
$phase = @{ Primary = ""; Inline = "" }
$counts.total++
if ($line -cmatch '\[(complete|in_progress|pending)\]') {
$phase.Inline = $Matches[1]
}
continue
}
if ($line -match '^ {0,3}#{1,3}(?:[ \t]+|$)') {
Add-PhaseStatus $counts $phase
$phase = $null
continue
}
if ($null -ne $phase -and -not $phase.Primary -and
$line -cmatch ('^ {0,3}(?:-[ \t]+)?\*\*' + $statusLabel + '\*\*[ \t]+(complete|in_progress|pending)(?:[ \t]|$)')) {
$phase.Primary = $Matches[1]
}
}
Add-PhaseStatus $counts $phase
return "$($counts.complete)/$($counts.total) complete, $($counts.in_progress) in_progress, $($counts.pending) pending"
}
function Show-PlanList {
if (-not (Test-Path -LiteralPath $PlanRoot -PathType Container)) {
Write-Output "No planning directory found."
return
}
if (-not (Test-WithinRoot $PlanRoot)) {
Write-Error "Error: planning directory is outside the project or cannot be verified."
exit 1
}
$active = Get-CurrentActivePlan
Write-Output "Available plans:"
Write-Output "[active] marks the shared .active_plan pointer; listing does not bind this session."
$found = $false
foreach ($plan in (Get-ChildItem -LiteralPath $PlanRoot -Directory -ErrorAction SilentlyContinue | Sort-Object Name)) {
if (-not (Test-ValidSlug $plan.Name) -or -not (Test-WithinRoot $plan.FullName)) { continue }
$planFile = Join-Path $plan.FullName "task_plan.md"
if (-not (Test-Path -LiteralPath $planFile -PathType Leaf) -or -not (Test-WithinRoot $planFile)) { continue }
try { $status = Get-PhaseStatus $planFile } catch { continue }
$marker = ""
if ($plan.Name -ceq $active) { $marker = " [active]" }
Write-Output "- $($plan.Name)$marker - $status"
$found = $true
}
if (-not $found) { Write-Output "No named plans found." }
}
if ($Help -or $PlanId -eq "--help" -or $PlanId -eq "-h") {
Write-Output "Usage: set-active-plan.ps1 [-List|-l|--list|PLAN_ID]"
exit 0
}
if ($List -or $PlanId -eq "--list" -or $PlanId -eq "-l") {
if ($List -and $PlanId) {
Write-Error "Error: list plans or set PLAN_ID in separate calls."
exit 1
}
Show-PlanList
exit 0
}
if ($PlanId -eq "") {
if (Test-Path $ActiveFile) {
$current = (Get-Content $ActiveFile -Raw -Encoding UTF8).Trim()
$current = ""
if ((Test-Path -LiteralPath $PlanRoot -PathType Container) -and -not (Test-WithinRoot $PlanRoot)) {
Write-Error "Error: planning directory is outside the project or cannot be verified."
exit 1
}
if (Test-WithinRoot $PlanRoot) { $current = Get-CurrentActivePlan }
if ($current) {
$planDir = Join-Path $PlanRoot $current
if ($current -ne "" -and (Test-Path $planDir)) {
if ((Test-Path -LiteralPath $planDir -PathType Container) -and (Test-WithinRoot $planDir)) {
Write-Output "Active plan: $current"
Write-Output "Path: $planDir"
} elseif ($current -ne "") {
Write-Output "Active plan pointer: $current (directory not found - stale pointer)"
} else {
Write-Output "No active plan set."
Write-Output "Active plan pointer: $current (directory not found or outside project - stale pointer)"
}
} else {
Write-Output "No active plan set."
@@ -29,21 +247,53 @@ if ($PlanId -eq "") {
exit 0
}
if (-not (Test-ValidSlug $PlanId)) {
Write-Error "Error: invalid plan ID. Use letters, numbers, underscores, dots, or hyphens; start with a letter, number, or underscore."
exit 1
}
$PlanDir = Join-Path $PlanRoot $PlanId
if (-not (Test-Path $PlanDir)) {
if (-not (Test-Path -LiteralPath $PlanDir -PathType Container)) {
Write-Error "Error: plan directory not found: $PlanDir"
Write-Error "Run: init-session.sh `"$PlanId`" to create it, or check .planning\ for available plans."
exit 1
}
if (-not (Test-Path $PlanRoot)) {
New-Item -ItemType Directory -Path $PlanRoot -Force | Out-Null
if (-not (Test-WithinRoot $PlanRoot) -or -not (Test-WithinRoot $PlanDir)) {
Write-Error "Error: plan directory must remain within the project."
exit 1
}
$activeItem = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
if ($activeItem -and -not (Test-SafeActiveFile)) {
Write-Error "Error: the active plan pointer must be a regular file within the project."
exit 1
}
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($ActiveFile, $PlanId, $utf8NoBom)
# Replace the directory entry instead of truncating an existing inode: a
# hardlinked pointer must not overwrite another file. The exclusive temporary
# file lives beside the pointer, keeping replacement on the same filesystem.
$tempFile = Join-Path $PlanRoot ('.active_plan.' + [guid]::NewGuid().ToString('N') + '.tmp')
$createdTemp = $false
try {
$stream = [IO.File]::Open($tempFile, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
$createdTemp = $true
try {
$bytes = $utf8NoBom.GetBytes($PlanId)
$stream.Write($bytes, 0, $bytes.Length)
$stream.Flush()
} finally { $stream.Dispose() }
if ($activeItem) {
[IO.File]::Replace($tempFile, $ActiveFile, [NullString]::Value)
} else {
[IO.File]::Move($tempFile, $ActiveFile)
}
} catch {
Write-Error "Error: could not set the active plan pointer: $($_.Exception.Message)"
exit 1
} finally {
if ($createdTemp -and (Test-Path -LiteralPath $tempFile)) {
Remove-Item -LiteralPath $tempFile -Force -ErrorAction SilentlyContinue
}
}
Write-Output "Active plan set to: $PlanId"
Write-Output "Path: $PlanDir"
Write-Output ""
+323 -38
View File
@@ -1,50 +1,335 @@
#!/bin/sh
# planning-with-files: set or display the active plan pointer.
#
# Usage:
# set-active-plan.sh <plan_id> — pin .planning/.active_plan to plan_id
# set-active-plan.sh — print the current active plan (if any)
#
# The active plan is stored in .planning/.active_plan and is read by
# resolve-plan-dir.sh when no $PLAN_ID env var is set.
set -e
# List saved named plans, show the shared pointer, or change that pointer.
# Usage: set-active-plan.sh [--list|-l|PLAN_ID]
# Operates on the current project; listing never binds a host or injects data.
set -eu
PLAN_ROOT="${PWD}/.planning"
ACTIVE_FILE="${PLAN_ROOT}/.active_plan"
PWF_ROOT_PIN=""
# No args → show current active plan
if [ "${1:-}" = "" ]; then
if [ -f "${ACTIVE_FILE}" ]; then
plan_id="$(tr -d '\r\n' < "${ACTIVE_FILE}")"
if [ -n "${plan_id}" ] && [ -d "${PLAN_ROOT}/${plan_id}" ]; then
echo "Active plan: ${plan_id}"
echo "Path: ${PLAN_ROOT}/${plan_id}"
elif [ -n "${plan_id}" ]; then
echo "Active plan pointer: ${plan_id} (directory not found — stale pointer)"
else
echo "No active plan set."
fi
# Use the resolver canonicalization policy without running plan selection.
slug_is_valid() {
case "$1" in
'') return 1 ;;
*[!A-Za-z0-9._-]*) return 1 ;;
[A-Za-z0-9_]*) return 0 ;;
esac
return 1
}
# Pure-sh backslash-to-forward-slash normalizer; result lands in $NORM_OUT.
# Windows-native coreutils builds (e.g. C:\Program Files\coreutils on PATH
# ahead of Git's usr/bin) canonicalize MSYS-style /c/... input to C:\-style
# backslash output. The containment prefix match below is written with forward
# slashes, so without this normalization every canonical pair mismatches and
# resolution silently fails. On POSIX systems paths contain no backslash and
# this is the identity. A literal backslash in a Unix filename normalizes to
# "/" and at worst fails containment — the safe direction. No subshell, no
# fork: plain parameter expansion in a loop.
norm_slashes() {
NORM_OUT=""
_ns_rest="$1"
while :; do
case "${_ns_rest}" in
*\\*)
NORM_OUT="${NORM_OUT}${_ns_rest%%\\*}/"
_ns_rest="${_ns_rest#*\\}"
;;
*)
NORM_OUT="${NORM_OUT}${_ns_rest}"
break
;;
esac
done
}
# Return true when a candidate path names the Microsoft Store WindowsApps
# directory. Store app aliases are not stable interpreter binaries and may
# present as executable while refusing script execution. Matching is
# case-insensitive and works before or after Windows slash normalization.
is_windowsapps_path() {
norm_slashes "$1"
case "${NORM_OUT}" in
[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]|\
[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]/*|\
*/[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]|\
*/[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]/*) return 0 ;;
esac
return 1
}
# Select only an interpreter path the caller explicitly trusted.
# PWF_TRUSTED_PYTHON is preferred; PYTHON_BIN remains a compatibility alias.
# PATH discovery is intentionally forbidden because resolver hooks can run in
# repositories that control PATH. Windows-native absolute paths are converted
# with Git Bash's fixed system cygpath, never a PATH-selected shim.
trusted_python() {
for _tp_candidate in "${PWF_TRUSTED_PYTHON:-}" "${PYTHON_BIN:-}"; do
[ -n "${_tp_candidate}" ] || continue
case "${_tp_candidate}" in
\\\\*|//*) continue ;;
[A-Za-z]:[\\/]*)
is_windowsapps_path "${_tp_candidate}" && continue
_tp_cygpath="/usr/bin/cygpath.exe"
[ -f "${_tp_cygpath}" ] && [ -x "${_tp_cygpath}" ] || continue
_tp_candidate="$("${_tp_cygpath}" -u "${_tp_candidate}" 2>/dev/null)" \
|| continue
;;
/*) ;;
*) continue ;;
esac
is_windowsapps_path "${_tp_candidate}" && continue
[ -f "${_tp_candidate}" ] || continue
[ -x "${_tp_candidate}" ] || continue
printf "%s\n" "${_tp_candidate}"
return 0
done
return 1
}
# Portable path canonicalizer. realpath first (Linux, modern coreutils),
# then readlink -f (older GNU), then an explicitly trusted Python interpreter.
# Prints the canonical absolute path on success; prints nothing and returns 1
# on a full miss so containment fails closed. No Python spawn on the happy
# path: realpath/readlink cover Linux, WSL, Git-Bash, and modern macOS.
canonicalize() {
target="$1"
if command -v realpath >/dev/null 2>&1; then
out="$(realpath "${target}" 2>/dev/null)" && [ -n "${out}" ] && {
printf "%s\n" "${out}"; return 0; }
fi
if command -v readlink >/dev/null 2>&1; then
out="$(readlink -f "${target}" 2>/dev/null)" && [ -n "${out}" ] && {
printf "%s\n" "${out}"; return 0; }
fi
_canonical_python="$(trusted_python)" || _canonical_python=""
if [ -n "${_canonical_python}" ]; then
out="$("${_canonical_python}" -I -c "import os,sys;print(os.path.realpath(sys.argv[1]))" "${target}" 2>/dev/null)" \
&& [ -n "${out}" ] && { printf "%s\n" "${out}"; return 0; }
fi
return 1
}
# Containment guard (security A1.3): a resolved plan dir must canonicalize to a
# path under the project root (the CWD the script runs from). A symlink inside
# a valid slug dir pointing at /etc or outside the workspace would otherwise let
# the hooks hash and inject an arbitrary file. On any violation we return 1 so
# the caller treats the candidate as unresolved and falls back safely.
#
# The root canonicalizes via the relative token "." rather than the $PWD
# string. On some Windows/MSYS setups (8.3 short names, the /tmp mount alias)
# realpath("$PWD") and realpath(relative-candidate) resolve through different
# code paths and land on differently-spelled-but-equal targets, so the prefix
# match below fails and resolution silently goes dark. "." resolves through
# the same physical-cwd path candidates already use (same fix inject-plan.sh
# received earlier; the resolver kept the $PWD form until now). Both sides are
# backslash-normalized before comparison for Windows-native canonicalizers.
# The root is computed once per run: the newest-mtime scan calls this guard
# per plan dir, and each canonicalize costs a process spawn on Windows.
#
# With a PWF_PLAN_ROOT pin (issue #212) containment is checked against THAT
# root instead of the cwd: candidates arrive ${PWF_PLAN_ROOT}/-prefixed, so
# both sides canonicalize through the same path spelling. Unpinned keeps the
# relative "." root — byte-identical to the legacy check.
ROOT_REAL=""
ROOT_REAL_SET=0
is_within_root() {
candidate="$1"
if [ "${ROOT_REAL_SET}" = "0" ]; then
ROOT_REAL="$(canonicalize "${PWF_ROOT_PIN:-.}")" || ROOT_REAL=""
norm_slashes "${ROOT_REAL}"
ROOT_REAL="${NORM_OUT}"
ROOT_REAL_SET=1
fi
# Canonicalize the candidate through its cwd-RELATIVE form whenever it
# lives under ${PWD}. The candidate string is built from ${PWD} (an MSYS
# long-form spelling), while the root canonicalizes from "." (the process
# cwd, which a caller may have set with an 8.3 short-form string). A
# Windows-native realpath does not unify those spellings, so canonicalizing
# both sides from the same cwd base is the only spelling-stable comparison.
# The emitted result keeps the original absolute candidate — only the
# containment check uses the relative form.
# Pinned resolution skips the rewrite: candidate and root then share the
# ${PWF_PLAN_ROOT} spelling, so both canonicalize directly from it.
if [ -n "${PWF_ROOT_PIN}" ]; then
check_target="${candidate}"
else
echo "No active plan set."
case "${candidate}" in
"${PWD}"/*) check_target=".${candidate#"${PWD}"}" ;;
*) check_target="${candidate}" ;;
esac
fi
cand_real="$(canonicalize "${check_target}")" || cand_real=""
norm_slashes "${cand_real}"
cand_real="${NORM_OUT}"
if [ -z "${ROOT_REAL}" ] || [ -z "${cand_real}" ]; then
# Slug validation blocks textual traversal, but only successful
# canonicalization can rule out a symlink/junction escape.
return 1
fi
case "${cand_real}" in
"${ROOT_REAL}"|"${ROOT_REAL}"/*) return 0 ;;
*) return 1 ;;
esac
}
# Each phase contributes at most one status. An explicit Status line wins
# over an inline heading marker. Fenced examples and unrelated sections are
# not phases. Keep this parser aligned with the PowerShell helper.
phase_status() {
awk '
function finish() {
if (phase) {
status = primary != "" ? primary : inline_status
if (status == "complete") complete++
else if (status == "in_progress") in_progress++
else if (status == "pending") pending++
}
phase = 0; primary = ""; inline_status = ""
}
{
line = $0
sub(/\r$/, "", line)
trimmed = line
sub(/^ */, "", trimmed)
if (line ~ /^ ? ? ?```/ || line ~ /^ ? ? ?~~~/) {
marker = substr(trimmed, 1, 1)
run = 0
while (substr(trimmed, run + 1, 1) == marker) run++
if (fence == "") { fence = marker; fence_length = run }
else if (marker == fence && run >= fence_length && substr(trimmed, run + 1) ~ /^[ \t]*$/) fence = ""
next
}
if (fence != "") next
if (line ~ /^ ? ? ?###[ \t]+(Phase|Fase|المرحلة|阶段|階段)[ \t]+[0-9]+([^0-9A-Za-z_]|$)/) {
finish(); phase = 1; total++
if (match(line, /\[(complete|in_progress|pending)\]/))
inline_status = substr(line, RSTART + 1, RLENGTH - 2)
} else if (line ~ /^ ? ? ?(###|##|#)([ \t]|$)/) {
finish()
} else if (phase && primary == "" && line ~ /^ ? ? ?(-[ \t]+)?\*\*(Status:|Estado:|الحالة:|状态:|狀態:)\*\*[ \t]+(complete|in_progress|pending)([ \t]|$)/) {
sub(/^ ? ? ?(-[ \t]+)?\*\*(Status:|Estado:|الحالة:|状态:|狀態:)\*\*[ \t]+/, "", line)
sub(/[ \t].*$/, "", line)
primary = line
}
}
END {
finish()
printf "%d/%d complete, %d in_progress, %d pending", complete, total, in_progress, pending
}
' < "$1"
}
current_active() {
if [ -f "${ACTIVE_FILE}" ] && is_within_root "${ACTIVE_FILE}"; then
_current="$(tr '\r' '\n' < "${ACTIVE_FILE}")"
# Windows editors and older PowerShell defaults can leave a UTF-8 BOM.
# Treat it as an encoding marker, not part of the shared plan slug.
_utf8_bom="$(printf '\357\273\277')"
case "${_current}" in
"${_utf8_bom}"*) _current="${_current#"${_utf8_bom}"}" ;;
esac
slug_is_valid "${_current}" && printf '%s\n' "${_current}"
fi
return 0
}
list_plans() {
if [ ! -d "${PLAN_ROOT}" ]; then
printf '%s\n' 'No planning directory found.'
return 0
fi
if ! is_within_root "${PLAN_ROOT}"; then
printf '%s\n' 'Error: planning directory is outside the project or cannot be verified.' >&2
return 1
fi
_active="$(current_active)"
_found=0
printf '%s\n' 'Available plans:'
for _dir in "${PLAN_ROOT}"/*; do
[ -d "${_dir}" ] || continue
_id="${_dir##*/}"
slug_is_valid "${_id}" || continue
is_within_root "${_dir}" || continue
_plan_file="${_dir}/task_plan.md"
[ -f "${_plan_file}" ] && [ -r "${_plan_file}" ] || continue
is_within_root "${_plan_file}" || continue
_status="$(phase_status "${_plan_file}")" || continue
_marker=''
if [ "${_id}" = "${_active}" ]; then _marker=' [active]'; fi
printf '%s\n' "- ${_id}${_marker} - ${_status}"
_found=1
done
if [ "${_found}" -eq 0 ]; then
printf '%s\n' 'No named plans found.'
else
printf '%s\n' '[active] marks the shared default pointer. Set PLAN_ID to pin a session.'
fi
}
if [ "$#" -gt 1 ]; then
printf '%s\n' 'Error: list plans or set PLAN_ID in separate calls.' >&2
exit 1
fi
case "${1:-}" in
--list|-l) list_plans; exit $? ;;
--help|-h)
printf '%s\n' 'Usage: set-active-plan.sh [--list|PLAN_ID]' \
'Lists saved named plans in the current project without selecting a plan.'
exit 0 ;;
esac
if [ "${1:-}" = '' ]; then
if [ -d "${PLAN_ROOT}" ] && ! is_within_root "${PLAN_ROOT}"; then
printf '%s\n' 'Error: planning directory is outside the project or cannot be verified.' >&2
exit 1
fi
plan_id="$(current_active)"
if [ -n "${plan_id}" ] && [ -d "${PLAN_ROOT}/${plan_id}" ] && is_within_root "${PLAN_ROOT}/${plan_id}"; then
printf '%s\n' "Active plan: ${plan_id}" "Path: ${PLAN_ROOT}/${plan_id}"
elif [ -n "${plan_id}" ]; then
printf '%s\n' "Active plan pointer: ${plan_id} (directory not found or outside project - stale pointer)"
else
printf '%s\n' 'No active plan set.'
fi
exit 0
fi
PLAN_ID="$1"
PLAN_DIR="${PLAN_ROOT}/${PLAN_ID}"
if [ ! -d "${PLAN_DIR}" ]; then
echo "Error: plan directory not found: ${PLAN_DIR}" >&2
echo "Run: init-session.sh \"${PLAN_ID}\" to create it, or check .planning/ for available plans." >&2
if ! slug_is_valid "${PLAN_ID}"; then
printf '%s\n' 'Error: invalid plan ID. Use a named directory under .planning.' >&2
exit 1
fi
mkdir -p "${PLAN_ROOT}"
printf "%s\n" "${PLAN_ID}" > "${ACTIVE_FILE}"
echo "Active plan set to: ${PLAN_ID}"
echo "Path: ${PLAN_DIR}"
echo ""
echo "To pin this terminal session only:"
echo " export PLAN_ID=${PLAN_ID}"
PLAN_DIR="${PLAN_ROOT}/${PLAN_ID}"
if [ ! -d "${PLAN_DIR}" ]; then
printf '%s\n' "Error: plan directory not found: ${PLAN_DIR}" \
"Run: init-session.sh \"${PLAN_ID}\" to create it, or use --list to see available plans." >&2
exit 1
fi
if ! is_within_root "${PLAN_ROOT}" || ! is_within_root "${PLAN_DIR}"; then
printf '%s\n' 'Error: plan directory is outside the project or cannot be verified.' >&2
exit 1
fi
# A pre-existing pointer must itself be a contained regular file before writing.
if { [ -e "${ACTIVE_FILE}" ] || [ -L "${ACTIVE_FILE}" ]; } &&
{ [ -L "${ACTIVE_FILE}" ] || [ ! -f "${ACTIVE_FILE}" ] || ! is_within_root "${ACTIVE_FILE}"; }; then
printf '%s\n' 'Error: active plan pointer is not a safe file inside the project.' >&2
exit 1
fi
# Replace the pointer atomically instead of truncating a possible hardlink.
# mktemp creates a private, exclusive file beside the destination.
temp_file="$(mktemp "${PLAN_ROOT}/.active_plan.XXXXXX")" || {
printf '%s\n' 'Error: could not create the active plan pointer.' >&2
exit 1
}
trap 'rm -f "${temp_file}"' EXIT
trap 'exit 1' HUP INT TERM
printf '%s\n' "${PLAN_ID}" > "${temp_file}"
if ! mv -f "${temp_file}" "${ACTIVE_FILE}"; then
printf '%s\n' 'Error: could not replace the active plan pointer.' >&2
exit 1
fi
trap - EXIT HUP INT TERM
printf '%s\n' "Active plan set to: ${PLAN_ID}" "Path: ${PLAN_DIR}" '' \
'To pin this terminal session only:' " export PLAN_ID=${PLAN_ID}"
+8 -2
View File
@@ -28,7 +28,7 @@ hooks:
- type: command
command: "[ -n \"${CLAUDE_PLUGIN_ROOT:-}\" ] && exit 0; SH=\"${CLAUDE_SKILL_DIR}/scripts/skill-hook.sh\"; [ -f \"$SH\" ] || SH=$(ls \"$HOME/.claude/skills/planning-with-files/scripts/skill-hook.sh\" \"$HOME/.claude/plugins/marketplaces/planning-with-files/scripts/skill-hook.sh\" 2>/dev/null | head -1); [ -n \"$SH\" ] && [ -f \"$SH\" ] && sh \"$SH\" --event=precompact; exit 0"
metadata:
version: "3.17.2"
version: "3.18.2"
---
# Planning with Files
@@ -223,13 +223,19 @@ Copy these templates to start:
Helper scripts for automation:
- `scripts/init-session.sh` — Initialize planning files. With a name arg, creates an isolated plan under `.planning/YYYY-MM-DD-<slug>/` for parallel task workflows. Without args, writes `task_plan.md` at project root (legacy mode, backward-compatible).
- `scripts/set-active-plan.sh` — Switch the active plan pointer (`.planning/.active_plan`). Run with a plan ID to switch; run without args to show which plan is current.
- `scripts/set-active-plan.sh` — Switch or inspect the active plan pointer (`.planning/.active_plan`). Run with `--list` to show named plans and phase counts, with a plan ID to switch, or without args to show which plan is current.
- `scripts/resolve-plan-dir.sh` — Resolve the active plan directory. A set `$PLAN_ID` is a binding: it resolves or resolution stops, never another plan (issue #237). With no `$PLAN_ID`, multiple named plans refuse selection. A single named plan may use `.planning/.active_plan` or discovery by mtime; otherwise resolution falls back to the project root (legacy). Used internally by hooks.
- `scripts/check-complete.sh` — Verify all phases in the active plan are complete.
- `scripts/session-catchup.py`: Explicit same-project session-record aggregation or bounded replay (`--metadata` / `--replay`); bare invocation does not access host history.
- `scripts/attest-plan.sh` (and `.ps1`) — Lock the current `task_plan.md` content with a SHA-256 attestation (v2.37.0). Hooks then refuse to inject plan content if the file diverges from the attested hash. Use `--show` to print the stored hash, `--clear` to remove the attestation. See `/plan-attest` command.
- `scripts/plan-doctor.sh` — One-pass self-check for the mechanisms that fail silently (v3.6.0): plan resolution, hook injection, canonicalizer path shape, attestation state, install surfaces, per-fire hook latency. Run it whenever hooks seem quiet or after installing on a new machine. See `/plan-doctor` command.
### List saved plans
To find a task before resuming it, run `sh "<skill-dir>/scripts/set-active-plan.sh" --list` or, in Windows PowerShell, `& "<skill-dir>/scripts/set-active-plan.ps1" -List`. Replace `<skill-dir>` with this installed skill directory and keep your current directory at the project root.
This read-only command lists named plans and phase progress under the current directory's `.planning/`. `[active]` marks the shared default pointer; it does not bind a session. Concurrent tasks still require each host's `PLAN_ID` or separate worktrees.
### Parallel task workflow
For independent tasks in the same repository, create a named plan for each and pin each agent host to its own plan:
@@ -1,27 +1,245 @@
# planning-with-files: set or display the active plan pointer (PowerShell).
# planning-with-files: set, display, or list the active plan pointer (PowerShell).
#
# Usage:
# .\set-active-plan.ps1 <plan_id> - pin .planning\.active_plan to plan_id
# .\set-active-plan.ps1 - print the current active plan (if any)
# .\set-active-plan.ps1 -List - list available named plans and phase counts
# .\set-active-plan.ps1 --list - equivalent to -List and -l
param(
[string]$PlanId = ""
[Parameter(Position = 0)]
[string]$PlanId = "",
[Alias('l', '-list')]
[switch]$List,
[Alias('h', '-help')]
[switch]$Help
)
$PlanRoot = Join-Path (Get-Location) ".planning"
$ProjectRoot = (Get-Location).Path
# Windows PowerShell 5.1 can silently change directories when -File inherits
# a cwd containing wildcard characters. Recover that physical cwd only for a
# direct invocation of this script; interactive Set-Location remains primary.
if ($PSVersionTable.PSVersion.Major -eq 5 -and
[System.Management.Automation.WildcardPattern]::ContainsWildcardCharacters([Environment]::CurrentDirectory)) {
$processArgs = [Environment]::GetCommandLineArgs()
for ($index = 0; $index -lt ($processArgs.Length - 1); $index++) {
if ($processArgs[$index] -ieq '-File') {
$entryScript = $processArgs[$index + 1]
if (-not [IO.Path]::IsPathRooted($entryScript)) {
$entryScript = Join-Path ([Environment]::CurrentDirectory) $entryScript
}
if ([IO.Path]::GetFullPath($entryScript) -eq [IO.Path]::GetFullPath($PSCommandPath)) {
$ProjectRoot = [Environment]::CurrentDirectory
}
break
}
}
}
$PlanRoot = Join-Path $ProjectRoot ".planning"
$ActiveFile = Join-Path $PlanRoot ".active_plan"
$script:IsWindowsHost = [Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT
# Resolve-Path can retain a junction's spelling. Like resolve-plan-dir.ps1,
# open the actual filesystem object before checking the project boundary.
# The Windows handle supports both directories and task_plan.md files.
function Get-FinalPath {
param([string]$Path)
if (-not ("PwfPlanListingNative" -as [type])) {
Add-Type -ErrorAction Stop -TypeDefinition @'
using System;
using System.ComponentModel;
using System.Runtime.InteropServices;
using System.Text;
using Microsoft.Win32.SafeHandles;
public static class PwfPlanListingNative {
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern SafeFileHandle CreateFileW(
string name, uint access, uint share, IntPtr security,
uint creation, uint flags, IntPtr template);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern uint GetFinalPathNameByHandleW(
SafeFileHandle handle, StringBuilder path, uint length, uint flags);
[DllImport("libc", SetLastError = true)]
private static extern IntPtr realpath(string path, IntPtr resolved);
[DllImport("libc")]
private static extern void free(IntPtr pointer);
public static string FinalPath(string path) {
if (Environment.OSVersion.Platform != PlatformID.Win32NT) {
IntPtr resolved = realpath(path, IntPtr.Zero);
if (resolved == IntPtr.Zero) throw new Win32Exception(Marshal.GetLastWin32Error());
try { return Marshal.PtrToStringAnsi(resolved); }
finally { free(resolved); }
}
// OPEN_EXISTING, FILE_SHARE_READ | WRITE | DELETE, BACKUP_SEMANTICS.
using (SafeFileHandle handle = CreateFileW(
path, 0, 7, IntPtr.Zero, 3, 0x02000000, IntPtr.Zero)) {
if (handle.IsInvalid) throw new Win32Exception(Marshal.GetLastWin32Error());
StringBuilder buffer = new StringBuilder(32768);
uint length = GetFinalPathNameByHandleW(handle, buffer, (uint)buffer.Capacity, 0);
if (length == 0 || length >= buffer.Capacity)
throw new Win32Exception(Marshal.GetLastWin32Error());
string result = buffer.ToString();
if (result.StartsWith(@"\\?\UNC\", StringComparison.OrdinalIgnoreCase))
return @"\\" + result.Substring(8);
if (result.StartsWith(@"\\?\", StringComparison.OrdinalIgnoreCase))
return result.Substring(4);
return result;
}
}
}
'@
}
return [PwfPlanListingNative]::FinalPath($Path)
}
function Test-WithinRoot {
param([string]$Path)
try {
$rootReal = (Get-FinalPath $ProjectRoot).TrimEnd('\', '/')
$pathReal = (Get-FinalPath $Path).TrimEnd('\', '/')
$comparison = [StringComparison]::Ordinal
if ($script:IsWindowsHost) { $comparison = [StringComparison]::OrdinalIgnoreCase }
return $pathReal.Equals($rootReal, $comparison) -or
$pathReal.StartsWith($rootReal + [IO.Path]::DirectorySeparatorChar, $comparison)
} catch {
return $false
}
}
function Test-ValidSlug {
param([string]$Name)
return $Name -cmatch '^[A-Za-z0-9_][A-Za-z0-9._-]*\z'
}
function Test-SafeActiveFile {
param([switch]$AllowLink)
$item = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
if (-not $item) { return $false }
# Reading may follow a verified in-project link; writing must not.
return -not $item.PSIsContainer -and
($AllowLink -or (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0)) -and
(Test-WithinRoot $ActiveFile)
}
function Get-CurrentActivePlan {
if (-not (Test-SafeActiveFile -AllowLink)) { return "" }
try {
$current = ([string](Get-Content -LiteralPath $ActiveFile -Raw -Encoding UTF8 -ErrorAction Stop)).Trim([char[]]"`r`n")
if (Test-ValidSlug $current) { return $current }
} catch { }
return ""
}
function Add-PhaseStatus {
param([hashtable]$Counts, $Phase)
if ($null -eq $Phase) { return }
$status = $Phase.Inline
if ($Phase.Primary) { $status = $Phase.Primary }
if ($status) { $Counts[$status]++ }
}
function Get-PhaseStatus {
param([string]$PlanFile)
$counts = @{ total = 0; complete = 0; in_progress = 0; pending = 0 }
$phase = $null
$fence = ""
$fenceLength = 0
# The shipped translations keep status identifiers in English. Unicode
# escapes preserve Windows PowerShell 5.1 compatibility without a BOM.
$phaseLabel = '(?:Phase|Fase|\u0627\u0644\u0645\u0631\u062d\u0644\u0629|\u9636\u6bb5|\u968e\u6bb5)'
$statusLabel = '(?:Status:|Estado:|\u0627\u0644\u062d\u0627\u0644\u0629:|\u72b6\u6001\uff1a|\u72c0\u614b\uff1a)'
# Read once; an empty file is a valid plan with zero phases.
$content = [string](Get-Content -LiteralPath $PlanFile -Raw -Encoding UTF8 -ErrorAction Stop)
foreach ($line in ($content -split '\r?\n')) {
if ($fence) {
$closing = '^ {0,3}' + [regex]::Escape($fence) + '{' + $fenceLength + ',}[ \t]*$'
if ($line -match $closing) { $fence = "" }
continue
}
if ($line -match '^ {0,3}(`{3,}|~{3,})') {
$fence = $Matches[1].Substring(0, 1)
$fenceLength = $Matches[1].Length
continue
}
if ($line -cmatch ('^ {0,3}###[ \t]+' + $phaseLabel + '[ \t]+[0-9]+(?:[^0-9A-Za-z_]|$)')) {
Add-PhaseStatus $counts $phase
$phase = @{ Primary = ""; Inline = "" }
$counts.total++
if ($line -cmatch '\[(complete|in_progress|pending)\]') {
$phase.Inline = $Matches[1]
}
continue
}
if ($line -match '^ {0,3}#{1,3}(?:[ \t]+|$)') {
Add-PhaseStatus $counts $phase
$phase = $null
continue
}
if ($null -ne $phase -and -not $phase.Primary -and
$line -cmatch ('^ {0,3}(?:-[ \t]+)?\*\*' + $statusLabel + '\*\*[ \t]+(complete|in_progress|pending)(?:[ \t]|$)')) {
$phase.Primary = $Matches[1]
}
}
Add-PhaseStatus $counts $phase
return "$($counts.complete)/$($counts.total) complete, $($counts.in_progress) in_progress, $($counts.pending) pending"
}
function Show-PlanList {
if (-not (Test-Path -LiteralPath $PlanRoot -PathType Container)) {
Write-Output "No planning directory found."
return
}
if (-not (Test-WithinRoot $PlanRoot)) {
Write-Error "Error: planning directory is outside the project or cannot be verified."
exit 1
}
$active = Get-CurrentActivePlan
Write-Output "Available plans:"
Write-Output "[active] marks the shared .active_plan pointer; listing does not bind this session."
$found = $false
foreach ($plan in (Get-ChildItem -LiteralPath $PlanRoot -Directory -ErrorAction SilentlyContinue | Sort-Object Name)) {
if (-not (Test-ValidSlug $plan.Name) -or -not (Test-WithinRoot $plan.FullName)) { continue }
$planFile = Join-Path $plan.FullName "task_plan.md"
if (-not (Test-Path -LiteralPath $planFile -PathType Leaf) -or -not (Test-WithinRoot $planFile)) { continue }
try { $status = Get-PhaseStatus $planFile } catch { continue }
$marker = ""
if ($plan.Name -ceq $active) { $marker = " [active]" }
Write-Output "- $($plan.Name)$marker - $status"
$found = $true
}
if (-not $found) { Write-Output "No named plans found." }
}
if ($Help -or $PlanId -eq "--help" -or $PlanId -eq "-h") {
Write-Output "Usage: set-active-plan.ps1 [-List|-l|--list|PLAN_ID]"
exit 0
}
if ($List -or $PlanId -eq "--list" -or $PlanId -eq "-l") {
if ($List -and $PlanId) {
Write-Error "Error: list plans or set PLAN_ID in separate calls."
exit 1
}
Show-PlanList
exit 0
}
if ($PlanId -eq "") {
if (Test-Path $ActiveFile) {
$current = (Get-Content $ActiveFile -Raw -Encoding UTF8).Trim()
$current = ""
if ((Test-Path -LiteralPath $PlanRoot -PathType Container) -and -not (Test-WithinRoot $PlanRoot)) {
Write-Error "Error: planning directory is outside the project or cannot be verified."
exit 1
}
if (Test-WithinRoot $PlanRoot) { $current = Get-CurrentActivePlan }
if ($current) {
$planDir = Join-Path $PlanRoot $current
if ($current -ne "" -and (Test-Path $planDir)) {
if ((Test-Path -LiteralPath $planDir -PathType Container) -and (Test-WithinRoot $planDir)) {
Write-Output "Active plan: $current"
Write-Output "Path: $planDir"
} elseif ($current -ne "") {
Write-Output "Active plan pointer: $current (directory not found - stale pointer)"
} else {
Write-Output "No active plan set."
Write-Output "Active plan pointer: $current (directory not found or outside project - stale pointer)"
}
} else {
Write-Output "No active plan set."
@@ -29,21 +247,53 @@ if ($PlanId -eq "") {
exit 0
}
if (-not (Test-ValidSlug $PlanId)) {
Write-Error "Error: invalid plan ID. Use letters, numbers, underscores, dots, or hyphens; start with a letter, number, or underscore."
exit 1
}
$PlanDir = Join-Path $PlanRoot $PlanId
if (-not (Test-Path $PlanDir)) {
if (-not (Test-Path -LiteralPath $PlanDir -PathType Container)) {
Write-Error "Error: plan directory not found: $PlanDir"
Write-Error "Run: init-session.sh `"$PlanId`" to create it, or check .planning\ for available plans."
exit 1
}
if (-not (Test-Path $PlanRoot)) {
New-Item -ItemType Directory -Path $PlanRoot -Force | Out-Null
if (-not (Test-WithinRoot $PlanRoot) -or -not (Test-WithinRoot $PlanDir)) {
Write-Error "Error: plan directory must remain within the project."
exit 1
}
$activeItem = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
if ($activeItem -and -not (Test-SafeActiveFile)) {
Write-Error "Error: the active plan pointer must be a regular file within the project."
exit 1
}
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($ActiveFile, $PlanId, $utf8NoBom)
# Replace the directory entry instead of truncating an existing inode: a
# hardlinked pointer must not overwrite another file. The exclusive temporary
# file lives beside the pointer, keeping replacement on the same filesystem.
$tempFile = Join-Path $PlanRoot ('.active_plan.' + [guid]::NewGuid().ToString('N') + '.tmp')
$createdTemp = $false
try {
$stream = [IO.File]::Open($tempFile, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
$createdTemp = $true
try {
$bytes = $utf8NoBom.GetBytes($PlanId)
$stream.Write($bytes, 0, $bytes.Length)
$stream.Flush()
} finally { $stream.Dispose() }
if ($activeItem) {
[IO.File]::Replace($tempFile, $ActiveFile, [NullString]::Value)
} else {
[IO.File]::Move($tempFile, $ActiveFile)
}
} catch {
Write-Error "Error: could not set the active plan pointer: $($_.Exception.Message)"
exit 1
} finally {
if ($createdTemp -and (Test-Path -LiteralPath $tempFile)) {
Remove-Item -LiteralPath $tempFile -Force -ErrorAction SilentlyContinue
}
}
Write-Output "Active plan set to: $PlanId"
Write-Output "Path: $PlanDir"
Write-Output ""
@@ -1,50 +1,335 @@
#!/bin/sh
# planning-with-files: set or display the active plan pointer.
#
# Usage:
# set-active-plan.sh <plan_id> — pin .planning/.active_plan to plan_id
# set-active-plan.sh — print the current active plan (if any)
#
# The active plan is stored in .planning/.active_plan and is read by
# resolve-plan-dir.sh when no $PLAN_ID env var is set.
set -e
# List saved named plans, show the shared pointer, or change that pointer.
# Usage: set-active-plan.sh [--list|-l|PLAN_ID]
# Operates on the current project; listing never binds a host or injects data.
set -eu
PLAN_ROOT="${PWD}/.planning"
ACTIVE_FILE="${PLAN_ROOT}/.active_plan"
PWF_ROOT_PIN=""
# No args → show current active plan
if [ "${1:-}" = "" ]; then
if [ -f "${ACTIVE_FILE}" ]; then
plan_id="$(tr -d '\r\n' < "${ACTIVE_FILE}")"
if [ -n "${plan_id}" ] && [ -d "${PLAN_ROOT}/${plan_id}" ]; then
echo "Active plan: ${plan_id}"
echo "Path: ${PLAN_ROOT}/${plan_id}"
elif [ -n "${plan_id}" ]; then
echo "Active plan pointer: ${plan_id} (directory not found — stale pointer)"
else
echo "No active plan set."
fi
# Use the resolver canonicalization policy without running plan selection.
slug_is_valid() {
case "$1" in
'') return 1 ;;
*[!A-Za-z0-9._-]*) return 1 ;;
[A-Za-z0-9_]*) return 0 ;;
esac
return 1
}
# Pure-sh backslash-to-forward-slash normalizer; result lands in $NORM_OUT.
# Windows-native coreutils builds (e.g. C:\Program Files\coreutils on PATH
# ahead of Git's usr/bin) canonicalize MSYS-style /c/... input to C:\-style
# backslash output. The containment prefix match below is written with forward
# slashes, so without this normalization every canonical pair mismatches and
# resolution silently fails. On POSIX systems paths contain no backslash and
# this is the identity. A literal backslash in a Unix filename normalizes to
# "/" and at worst fails containment — the safe direction. No subshell, no
# fork: plain parameter expansion in a loop.
norm_slashes() {
NORM_OUT=""
_ns_rest="$1"
while :; do
case "${_ns_rest}" in
*\\*)
NORM_OUT="${NORM_OUT}${_ns_rest%%\\*}/"
_ns_rest="${_ns_rest#*\\}"
;;
*)
NORM_OUT="${NORM_OUT}${_ns_rest}"
break
;;
esac
done
}
# Return true when a candidate path names the Microsoft Store WindowsApps
# directory. Store app aliases are not stable interpreter binaries and may
# present as executable while refusing script execution. Matching is
# case-insensitive and works before or after Windows slash normalization.
is_windowsapps_path() {
norm_slashes "$1"
case "${NORM_OUT}" in
[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]|\
[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]/*|\
*/[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]|\
*/[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]/*) return 0 ;;
esac
return 1
}
# Select only an interpreter path the caller explicitly trusted.
# PWF_TRUSTED_PYTHON is preferred; PYTHON_BIN remains a compatibility alias.
# PATH discovery is intentionally forbidden because resolver hooks can run in
# repositories that control PATH. Windows-native absolute paths are converted
# with Git Bash's fixed system cygpath, never a PATH-selected shim.
trusted_python() {
for _tp_candidate in "${PWF_TRUSTED_PYTHON:-}" "${PYTHON_BIN:-}"; do
[ -n "${_tp_candidate}" ] || continue
case "${_tp_candidate}" in
\\\\*|//*) continue ;;
[A-Za-z]:[\\/]*)
is_windowsapps_path "${_tp_candidate}" && continue
_tp_cygpath="/usr/bin/cygpath.exe"
[ -f "${_tp_cygpath}" ] && [ -x "${_tp_cygpath}" ] || continue
_tp_candidate="$("${_tp_cygpath}" -u "${_tp_candidate}" 2>/dev/null)" \
|| continue
;;
/*) ;;
*) continue ;;
esac
is_windowsapps_path "${_tp_candidate}" && continue
[ -f "${_tp_candidate}" ] || continue
[ -x "${_tp_candidate}" ] || continue
printf "%s\n" "${_tp_candidate}"
return 0
done
return 1
}
# Portable path canonicalizer. realpath first (Linux, modern coreutils),
# then readlink -f (older GNU), then an explicitly trusted Python interpreter.
# Prints the canonical absolute path on success; prints nothing and returns 1
# on a full miss so containment fails closed. No Python spawn on the happy
# path: realpath/readlink cover Linux, WSL, Git-Bash, and modern macOS.
canonicalize() {
target="$1"
if command -v realpath >/dev/null 2>&1; then
out="$(realpath "${target}" 2>/dev/null)" && [ -n "${out}" ] && {
printf "%s\n" "${out}"; return 0; }
fi
if command -v readlink >/dev/null 2>&1; then
out="$(readlink -f "${target}" 2>/dev/null)" && [ -n "${out}" ] && {
printf "%s\n" "${out}"; return 0; }
fi
_canonical_python="$(trusted_python)" || _canonical_python=""
if [ -n "${_canonical_python}" ]; then
out="$("${_canonical_python}" -I -c "import os,sys;print(os.path.realpath(sys.argv[1]))" "${target}" 2>/dev/null)" \
&& [ -n "${out}" ] && { printf "%s\n" "${out}"; return 0; }
fi
return 1
}
# Containment guard (security A1.3): a resolved plan dir must canonicalize to a
# path under the project root (the CWD the script runs from). A symlink inside
# a valid slug dir pointing at /etc or outside the workspace would otherwise let
# the hooks hash and inject an arbitrary file. On any violation we return 1 so
# the caller treats the candidate as unresolved and falls back safely.
#
# The root canonicalizes via the relative token "." rather than the $PWD
# string. On some Windows/MSYS setups (8.3 short names, the /tmp mount alias)
# realpath("$PWD") and realpath(relative-candidate) resolve through different
# code paths and land on differently-spelled-but-equal targets, so the prefix
# match below fails and resolution silently goes dark. "." resolves through
# the same physical-cwd path candidates already use (same fix inject-plan.sh
# received earlier; the resolver kept the $PWD form until now). Both sides are
# backslash-normalized before comparison for Windows-native canonicalizers.
# The root is computed once per run: the newest-mtime scan calls this guard
# per plan dir, and each canonicalize costs a process spawn on Windows.
#
# With a PWF_PLAN_ROOT pin (issue #212) containment is checked against THAT
# root instead of the cwd: candidates arrive ${PWF_PLAN_ROOT}/-prefixed, so
# both sides canonicalize through the same path spelling. Unpinned keeps the
# relative "." root — byte-identical to the legacy check.
ROOT_REAL=""
ROOT_REAL_SET=0
is_within_root() {
candidate="$1"
if [ "${ROOT_REAL_SET}" = "0" ]; then
ROOT_REAL="$(canonicalize "${PWF_ROOT_PIN:-.}")" || ROOT_REAL=""
norm_slashes "${ROOT_REAL}"
ROOT_REAL="${NORM_OUT}"
ROOT_REAL_SET=1
fi
# Canonicalize the candidate through its cwd-RELATIVE form whenever it
# lives under ${PWD}. The candidate string is built from ${PWD} (an MSYS
# long-form spelling), while the root canonicalizes from "." (the process
# cwd, which a caller may have set with an 8.3 short-form string). A
# Windows-native realpath does not unify those spellings, so canonicalizing
# both sides from the same cwd base is the only spelling-stable comparison.
# The emitted result keeps the original absolute candidate — only the
# containment check uses the relative form.
# Pinned resolution skips the rewrite: candidate and root then share the
# ${PWF_PLAN_ROOT} spelling, so both canonicalize directly from it.
if [ -n "${PWF_ROOT_PIN}" ]; then
check_target="${candidate}"
else
echo "No active plan set."
case "${candidate}" in
"${PWD}"/*) check_target=".${candidate#"${PWD}"}" ;;
*) check_target="${candidate}" ;;
esac
fi
cand_real="$(canonicalize "${check_target}")" || cand_real=""
norm_slashes "${cand_real}"
cand_real="${NORM_OUT}"
if [ -z "${ROOT_REAL}" ] || [ -z "${cand_real}" ]; then
# Slug validation blocks textual traversal, but only successful
# canonicalization can rule out a symlink/junction escape.
return 1
fi
case "${cand_real}" in
"${ROOT_REAL}"|"${ROOT_REAL}"/*) return 0 ;;
*) return 1 ;;
esac
}
# Each phase contributes at most one status. An explicit Status line wins
# over an inline heading marker. Fenced examples and unrelated sections are
# not phases. Keep this parser aligned with the PowerShell helper.
phase_status() {
awk '
function finish() {
if (phase) {
status = primary != "" ? primary : inline_status
if (status == "complete") complete++
else if (status == "in_progress") in_progress++
else if (status == "pending") pending++
}
phase = 0; primary = ""; inline_status = ""
}
{
line = $0
sub(/\r$/, "", line)
trimmed = line
sub(/^ */, "", trimmed)
if (line ~ /^ ? ? ?```/ || line ~ /^ ? ? ?~~~/) {
marker = substr(trimmed, 1, 1)
run = 0
while (substr(trimmed, run + 1, 1) == marker) run++
if (fence == "") { fence = marker; fence_length = run }
else if (marker == fence && run >= fence_length && substr(trimmed, run + 1) ~ /^[ \t]*$/) fence = ""
next
}
if (fence != "") next
if (line ~ /^ ? ? ?###[ \t]+(Phase|Fase|المرحلة|阶段|階段)[ \t]+[0-9]+([^0-9A-Za-z_]|$)/) {
finish(); phase = 1; total++
if (match(line, /\[(complete|in_progress|pending)\]/))
inline_status = substr(line, RSTART + 1, RLENGTH - 2)
} else if (line ~ /^ ? ? ?(###|##|#)([ \t]|$)/) {
finish()
} else if (phase && primary == "" && line ~ /^ ? ? ?(-[ \t]+)?\*\*(Status:|Estado:|الحالة:|状态:|狀態:)\*\*[ \t]+(complete|in_progress|pending)([ \t]|$)/) {
sub(/^ ? ? ?(-[ \t]+)?\*\*(Status:|Estado:|الحالة:|状态:|狀態:)\*\*[ \t]+/, "", line)
sub(/[ \t].*$/, "", line)
primary = line
}
}
END {
finish()
printf "%d/%d complete, %d in_progress, %d pending", complete, total, in_progress, pending
}
' < "$1"
}
current_active() {
if [ -f "${ACTIVE_FILE}" ] && is_within_root "${ACTIVE_FILE}"; then
_current="$(tr '\r' '\n' < "${ACTIVE_FILE}")"
# Windows editors and older PowerShell defaults can leave a UTF-8 BOM.
# Treat it as an encoding marker, not part of the shared plan slug.
_utf8_bom="$(printf '\357\273\277')"
case "${_current}" in
"${_utf8_bom}"*) _current="${_current#"${_utf8_bom}"}" ;;
esac
slug_is_valid "${_current}" && printf '%s\n' "${_current}"
fi
return 0
}
list_plans() {
if [ ! -d "${PLAN_ROOT}" ]; then
printf '%s\n' 'No planning directory found.'
return 0
fi
if ! is_within_root "${PLAN_ROOT}"; then
printf '%s\n' 'Error: planning directory is outside the project or cannot be verified.' >&2
return 1
fi
_active="$(current_active)"
_found=0
printf '%s\n' 'Available plans:'
for _dir in "${PLAN_ROOT}"/*; do
[ -d "${_dir}" ] || continue
_id="${_dir##*/}"
slug_is_valid "${_id}" || continue
is_within_root "${_dir}" || continue
_plan_file="${_dir}/task_plan.md"
[ -f "${_plan_file}" ] && [ -r "${_plan_file}" ] || continue
is_within_root "${_plan_file}" || continue
_status="$(phase_status "${_plan_file}")" || continue
_marker=''
if [ "${_id}" = "${_active}" ]; then _marker=' [active]'; fi
printf '%s\n' "- ${_id}${_marker} - ${_status}"
_found=1
done
if [ "${_found}" -eq 0 ]; then
printf '%s\n' 'No named plans found.'
else
printf '%s\n' '[active] marks the shared default pointer. Set PLAN_ID to pin a session.'
fi
}
if [ "$#" -gt 1 ]; then
printf '%s\n' 'Error: list plans or set PLAN_ID in separate calls.' >&2
exit 1
fi
case "${1:-}" in
--list|-l) list_plans; exit $? ;;
--help|-h)
printf '%s\n' 'Usage: set-active-plan.sh [--list|PLAN_ID]' \
'Lists saved named plans in the current project without selecting a plan.'
exit 0 ;;
esac
if [ "${1:-}" = '' ]; then
if [ -d "${PLAN_ROOT}" ] && ! is_within_root "${PLAN_ROOT}"; then
printf '%s\n' 'Error: planning directory is outside the project or cannot be verified.' >&2
exit 1
fi
plan_id="$(current_active)"
if [ -n "${plan_id}" ] && [ -d "${PLAN_ROOT}/${plan_id}" ] && is_within_root "${PLAN_ROOT}/${plan_id}"; then
printf '%s\n' "Active plan: ${plan_id}" "Path: ${PLAN_ROOT}/${plan_id}"
elif [ -n "${plan_id}" ]; then
printf '%s\n' "Active plan pointer: ${plan_id} (directory not found or outside project - stale pointer)"
else
printf '%s\n' 'No active plan set.'
fi
exit 0
fi
PLAN_ID="$1"
PLAN_DIR="${PLAN_ROOT}/${PLAN_ID}"
if [ ! -d "${PLAN_DIR}" ]; then
echo "Error: plan directory not found: ${PLAN_DIR}" >&2
echo "Run: init-session.sh \"${PLAN_ID}\" to create it, or check .planning/ for available plans." >&2
if ! slug_is_valid "${PLAN_ID}"; then
printf '%s\n' 'Error: invalid plan ID. Use a named directory under .planning.' >&2
exit 1
fi
mkdir -p "${PLAN_ROOT}"
printf "%s\n" "${PLAN_ID}" > "${ACTIVE_FILE}"
echo "Active plan set to: ${PLAN_ID}"
echo "Path: ${PLAN_DIR}"
echo ""
echo "To pin this terminal session only:"
echo " export PLAN_ID=${PLAN_ID}"
PLAN_DIR="${PLAN_ROOT}/${PLAN_ID}"
if [ ! -d "${PLAN_DIR}" ]; then
printf '%s\n' "Error: plan directory not found: ${PLAN_DIR}" \
"Run: init-session.sh \"${PLAN_ID}\" to create it, or use --list to see available plans." >&2
exit 1
fi
if ! is_within_root "${PLAN_ROOT}" || ! is_within_root "${PLAN_DIR}"; then
printf '%s\n' 'Error: plan directory is outside the project or cannot be verified.' >&2
exit 1
fi
# A pre-existing pointer must itself be a contained regular file before writing.
if { [ -e "${ACTIVE_FILE}" ] || [ -L "${ACTIVE_FILE}" ]; } &&
{ [ -L "${ACTIVE_FILE}" ] || [ ! -f "${ACTIVE_FILE}" ] || ! is_within_root "${ACTIVE_FILE}"; }; then
printf '%s\n' 'Error: active plan pointer is not a safe file inside the project.' >&2
exit 1
fi
# Replace the pointer atomically instead of truncating a possible hardlink.
# mktemp creates a private, exclusive file beside the destination.
temp_file="$(mktemp "${PLAN_ROOT}/.active_plan.XXXXXX")" || {
printf '%s\n' 'Error: could not create the active plan pointer.' >&2
exit 1
}
trap 'rm -f "${temp_file}"' EXIT
trap 'exit 1' HUP INT TERM
printf '%s\n' "${PLAN_ID}" > "${temp_file}"
if ! mv -f "${temp_file}" "${ACTIVE_FILE}"; then
printf '%s\n' 'Error: could not replace the active plan pointer.' >&2
exit 1
fi
trap - EXIT HUP INT TERM
printf '%s\n' "Active plan set to: ${PLAN_ID}" "Path: ${PLAN_DIR}" '' \
'To pin this terminal session only:' " export PLAN_ID=${PLAN_ID}"
+50
View File
@@ -0,0 +1,50 @@
"""BOM-prefixed shell pointers retain their bytes in display and listing modes."""
from __future__ import annotations
import codecs
import os
import shutil
import stat
import subprocess
from pathlib import Path
import pytest
SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "set-active-plan.sh"
SH = shutil.which("sh")
@pytest.mark.skipif(SH is None, reason="requires a POSIX sh")
@pytest.mark.parametrize("ending", [b"", b"\n", b"\r\n"], ids=["no-newline", "lf", "crlf"])
def test_bom_pointer_display_and_listing_preserve_readonly_bytes(tmp_path, ending):
plan = tmp_path / ".planning" / "alpha"
plan.mkdir(parents=True)
(plan / "task_plan.md").write_text("### Phase 1: Work [pending]\n", encoding="utf-8")
pointer = tmp_path / ".planning" / ".active_plan"
original_bytes = codecs.BOM_UTF8 + b"alpha" + ending
pointer.write_bytes(original_bytes)
original_mode = pointer.stat().st_mode
original_mtime = pointer.stat().st_mtime_ns
pointer.chmod(stat.S_IREAD)
env = os.environ.copy()
for name in ("PLAN_ID", "PWF_PLAN_ROOT", "PLANNING_DISABLED"):
env.pop(name, None)
try:
for args in ([], ["--list"]):
result = subprocess.run(
[SH, str(SCRIPT), *args], cwd=tmp_path, env=env,
capture_output=True, text=True, encoding="utf-8",
timeout=60, check=False,
)
assert result.returncode == 0, result.stdout + result.stderr
assert not result.stderr.strip(), result.stderr
if args:
assert any(line.startswith("- alpha [active]")
for line in result.stdout.splitlines()), result.stdout
else:
assert "Active plan: alpha" in result.stdout, result.stdout
assert pointer.read_bytes() == original_bytes
assert pointer.stat().st_mtime_ns == original_mtime
finally:
pointer.chmod(original_mode)
+225
View File
@@ -0,0 +1,225 @@
"""Regression coverage for shell-adapter Python import isolation."""
from __future__ import annotations
import json
import os
import shutil
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
SH = shutil.which("sh")
# Use the same Git for Windows installation, not a WSL bash launcher.
BASH = shutil.which("bash", path=str(Path(SH).parent)) if os.name == "nt" and SH else shutil.which("bash")
CODEX_HOOKS = REPO / ".codex" / "hooks"
GEMINI_HOOKS = REPO / ".gemini" / "hooks"
COPILOT_HOOKS = REPO / ".github" / "hooks" / "scripts"
PYTHON_SHELL_HOOKS = (
GEMINI_HOOKS / "before-model.sh",
GEMINI_HOOKS / "before-tool.sh",
GEMINI_HOOKS / "session-start.sh",
GEMINI_HOOKS / "session-end.sh",
COPILOT_HOOKS / "session-start.sh",
COPILOT_HOOKS / "pre-tool-use.sh",
COPILOT_HOOKS / "error-occurred.sh",
)
CODEX_PYTHON_SHELL_HOOKS = (
CODEX_HOOKS / "pre-tool-use.sh",
CODEX_HOOKS / "session-start.sh",
CODEX_HOOKS / "user-prompt-submit.sh",
)
def shell_python_is_usable() -> bool:
if not BASH:
return False
result = subprocess.run(
[
str(BASH),
"-c",
'PY=$(command -v python3 || command -v python); [ -n "$PY" ] && "$PY" -I -c "import json"',
],
capture_output=True,
text=True,
timeout=10,
)
return result.returncode == 0
@unittest.skipUnless(shell_python_is_usable(), "shell Python is not usable")
class AdapterPythonIsolationTests(unittest.TestCase):
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory()
self.cwd = Path(self._tmp.name)
(self.cwd / "task_plan.md").write_text(
"# Task Plan\n## Current Phase: hardening\n### Phase 1: Work\n"
"**Status:** in_progress\n",
encoding="utf-8",
)
(self.cwd / "json.py").write_text(
"open('PLANTED_json', 'w').close()\nraise RuntimeError('shadow loaded')\n",
encoding="utf-8",
)
def tearDown(self) -> None:
self._tmp.cleanup()
def run_hook(self, script: Path, input_data: str = "{}") -> dict:
result = subprocess.run(
# Gemini executes Bash-shebang scripts directly; Copilot declares
# these hooks in its "bash" field. POSIX sh can be dash on Linux.
[str(BASH), str(script)],
cwd=str(self.cwd),
env=dict(os.environ, PYTHONUTF8="1"),
input=input_data,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=60,
)
self.assertEqual(0, result.returncode, result.stderr)
return json.loads(result.stdout.lstrip("\ufeff"))
def test_gemini_json_encoding_ignores_project_json_module(self) -> None:
for name in ("before-model.sh", "before-tool.sh"):
with self.subTest(hook=name):
marker = self.cwd / "PLANTED_json"
marker.unlink(missing_ok=True)
payload = self.run_hook(GEMINI_HOOKS / name)
self.assertFalse(marker.exists(), f"{name} imported project json.py")
self.assertIn("hardening", json.dumps(payload))
def test_copilot_python_paths_ignore_project_json_module(self) -> None:
cases = (
("session-start.sh", "{}"),
("pre-tool-use.sh", "{}"),
("error-occurred.sh", '{"error":{"message":"fixture failure"}}'),
)
for name, input_data in cases:
with self.subTest(hook=name):
marker = self.cwd / "PLANTED_json"
marker.unlink(missing_ok=True)
payload = self.run_hook(COPILOT_HOOKS / name, input_data)
self.assertFalse(marker.exists(), f"{name} imported project json.py")
self.assertIn("additionalContext", payload.get("hookSpecificOutput", {}))
def test_copilot_unicode_error_survives_isolated_python(self) -> None:
message = "中文 العربية русский Grüße"
# Escaped JSON becomes real Unicode inside the Python parser. Isolated
# mode ignores PYTHONUTF8, so a Windows locale encoding loses this event.
payload = self.run_hook(
COPILOT_HOOKS / "error-occurred.sh",
json.dumps({"error": {"message": message}}, ensure_ascii=True),
)
context = payload.get("hookSpecificOutput", {}).get("additionalContext", "")
self.assertIn(message, context)
@unittest.skipUnless(SH, "sh is not available")
class CodexPythonIsolationTests(unittest.TestCase):
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory()
self.cwd = Path(self._tmp.name)
(self.cwd / "task_plan.md").write_text(
"# Task Plan\n### Phase 1: Work\n**Status:** in_progress\n",
encoding="utf-8",
)
(self.cwd / "progress.md").write_text("# Progress\n", encoding="utf-8")
(self.cwd / "hashlib.py").write_text(
"open('PLANTED_hashlib', 'w').close()\nraise RuntimeError('shadow loaded')\n",
encoding="utf-8",
)
def tearDown(self) -> None:
self._tmp.cleanup()
def run_hook(self, name: str, cwd: Path | None = None) -> subprocess.CompletedProcess[str]:
hook_cwd = self.cwd if cwd is None else cwd
env = dict(os.environ, PYTHONUTF8="1")
env["PYTHON_BIN"] = sys.executable
env["PWF_PLAN_ROOT"] = str(hook_cwd)
return subprocess.run(
[str(SH), str(CODEX_HOOKS / name)],
cwd=str(hook_cwd),
env=env,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=60,
)
def test_codex_shell_python_ignores_project_hashlib_module(self) -> None:
cases = (
("user-prompt-submit.sh", "ACTIVE PLAN", "stdout"),
("pre-tool-use.sh", "# Task Plan", "stderr"),
("session-start.sh", "ACTIVE PLAN", "stdout"),
)
for name, expected, stream in cases:
with self.subTest(hook=name):
marker = self.cwd / "PLANTED_hashlib"
marker.unlink(missing_ok=True)
result = self.run_hook(name)
self.assertEqual(0, result.returncode, result.stderr)
self.assertFalse(marker.exists(), f"{name} imported project hashlib.py")
self.assertIn(expected, getattr(result, stream))
def test_codex_unicode_plan_root_survives_isolated_python(self) -> None:
project = self.cwd / "中文"
project.mkdir()
shutil.copyfile(self.cwd / "task_plan.md", project / "task_plan.md")
for name in ("user-prompt-submit.sh", "session-start.sh"):
with self.subTest(hook=name):
result = self.run_hook(name, cwd=project)
self.assertEqual(0, result.returncode, result.stderr)
self.assertIn("ACTIVE PLAN", result.stdout)
self.assertIn("# Task Plan", result.stdout)
class AdapterPythonIsolationContractTests(unittest.TestCase):
def test_every_adapter_python_invocation_uses_isolated_mode(self) -> None:
for script in PYTHON_SHELL_HOOKS:
with self.subTest(hook=script.relative_to(REPO)):
text = script.read_text(encoding="utf-8")
invocations = [
line
for line in text.splitlines()
if "$($PYTHON" in line or "| $PYTHON" in line
]
self.assertTrue(invocations, f"no Python invocation found in {script}")
for line in invocations:
self.assertIn(
"$PYTHON -I -X utf8 ",
line,
f"non-isolated Python invocation in {script.relative_to(REPO)}: {line}",
)
def test_every_codex_shell_python_invocation_uses_isolated_mode(self) -> None:
variables = ('"$PYTHON_BIN"', '"$PWF_PYTHON"', '"$PIN_PYTHON"', '"$_tp_candidate"')
for script in CODEX_PYTHON_SHELL_HOOKS:
with self.subTest(hook=script.relative_to(REPO)):
text = script.read_text(encoding="utf-8")
invocations = [
line
for line in text.splitlines()
if any(variable in line for variable in variables)
and (" -c " in line or (".py\"" in line and " -f " not in line))
]
self.assertTrue(invocations, f"no Python invocation found in {script}")
for line in invocations:
self.assertIn(
" -I -X utf8 ",
line,
f"non-isolated Python invocation in {script.relative_to(REPO)}: {line}",
)
if __name__ == "__main__":
unittest.main()
+430
View File
@@ -0,0 +1,430 @@
"""Exercise plan listing through the real shell and PowerShell entry points.
Listing is a read-only inventory of the current directory's named plans. Phase
counts describe actual phase sections, and filesystem links cannot expose plans
outside that inventory. PowerShell is invoked with -File, as documented for users.
"""
from __future__ import annotations
import os
import re
import shutil
import stat
import subprocess
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[1]
SCRIPTS = REPO_ROOT / "scripts"
RUNTIMES = (
("sh", shutil.which("sh")),
("powershell", shutil.which("powershell.exe") or shutil.which("powershell")),
("pwsh", shutil.which("pwsh.exe") or shutil.which("pwsh")),
)
@pytest.fixture(params=RUNTIMES, ids=lambda runtime: runtime[0])
def runtime(request):
name, executable = request.param
if executable is None:
pytest.skip(f"{name} is not installed")
return name, executable
def run_selector(runtime, root: Path, *args: str, env_extra=None):
name, executable = runtime
if name == "sh":
command = [executable, str(SCRIPTS / "set-active-plan.sh"), *args]
else:
command = [
executable, "-NoLogo", "-NoProfile", "-NonInteractive",
"-ExecutionPolicy", "Bypass", "-File",
str(SCRIPTS / "set-active-plan.ps1"), *args,
]
env = os.environ.copy()
for key in ("PLAN_ID", "PWF_PLAN_ROOT", "PLANNING_DISABLED"):
env.pop(key, None)
env.update(env_extra or {})
return subprocess.run(
command, cwd=root, env=env, text=True, encoding="utf-8",
errors="replace", capture_output=True, timeout=60, check=False,
)
def listed(runtime, root: Path, option="--list", **kwargs) -> str:
result = run_selector(runtime, root, option, **kwargs)
assert result.returncode == 0, result.stdout + result.stderr
assert not result.stderr.strip(), result.stderr
return result.stdout
def rejected_root_listing(runtime, root: Path) -> str:
result = run_selector(runtime, root, "--list")
assert result.returncode != 0, result.stdout + result.stderr
assert "outside the project" in result.stderr, result.stdout + result.stderr
return result.stdout
def plan(root: Path, slug: str, text: str = "") -> Path:
directory = root / ".planning" / slug
directory.mkdir(parents=True)
path = directory / "task_plan.md"
path.write_text(text, encoding="utf-8")
return path
def row(output: str, slug: str) -> str:
matches = [line for line in output.splitlines()
if re.match(r"^- " + re.escape(slug) + r"(?:\s|$)", line)]
assert len(matches) == 1, output
return matches[0]
def assert_counts(output: str, slug: str, complete: int, total: int,
in_progress: int = 0, pending: int = 0) -> None:
expected = f"{complete}/{total} complete, {in_progress} in_progress, {pending} pending"
assert expected in row(output, slug), output
@pytest.mark.parametrize("option", ["--list", "-l"])
def test_list_aliases_work_through_file_invocation(runtime, tmp_path, option):
plan(tmp_path, "alpha", "### Phase 1: Inspect [complete]\n")
assert_counts(listed(runtime, tmp_path, option), "alpha", 1, 1)
def test_powershell_native_list_switch(runtime, tmp_path):
if runtime[0] == "sh":
pytest.skip("-List is the native PowerShell spelling")
plan(tmp_path, "alpha", "### Phase 1: Inspect [complete]\n")
assert_counts(listed(runtime, tmp_path, "-List"), "alpha", 1, 1)
def test_mixed_inline_and_status_formats_count_each_phase(runtime, tmp_path):
plan(tmp_path, "mixed", """# Task plan
### Phase 1: Discover [complete]
### Phase 2: Implement
**Status:** complete
### Phase 3: Verify [complete]
**Status:** complete
### Phase 4: Document [in_progress]
### Phase 5: Package
**Status:** pending
### Phase 6: Release [pending]
**Status:** pending
""")
assert_counts(listed(runtime, tmp_path), "mixed", 3, 6, 1, 2)
def test_duplicate_status_markers_count_one_phase(runtime, tmp_path):
plan(tmp_path, "duplicates", """### Phase 1: Work [complete] [complete]
**Status:** complete
**Status:** complete
### Phase 2: Next [pending] [pending]
**Status:** pending
**Status:** pending
""")
assert_counts(listed(runtime, tmp_path), "duplicates", 1, 2, 0, 1)
def test_first_recognized_status_line_overrides_heading_status(runtime, tmp_path):
plan(tmp_path, "updated", """### Phase 1: Work [pending]
**Status:** unknown
**Status:** complete
**Status:** pending
### Phase 2: Continue [complete]
**Status:** in_progress
""")
assert_counts(listed(runtime, tmp_path), "updated", 1, 2, 1, 0)
def test_non_phase_prose_and_fenced_examples_do_not_count(runtime, tmp_path):
plan(tmp_path, "examples", """# Task plan
**Status:** complete
An unrelated [pending] reference.
```markdown
### Phase 99: Example [complete]
**Status:** complete
```
### Phase 1: Real work [in_progress]
A prose reference to [complete] does not finish this phase.
~~~markdown
### Phase 98: Another example [pending]
**Status:** pending
~~~
### Phase 2: No status yet
````markdown
### Phase 97: Nested example [complete]
```
**Status:** complete
````
## Notes
**Status:** complete
### An unrelated heading
**Status:** pending
""")
assert_counts(listed(runtime, tmp_path), "examples", 0, 2, 1, 0)
@pytest.mark.parametrize("example", [
"\n **Status:** complete\n",
"###\n**Status:** complete\n",
], ids=["indented-code", "empty-section-heading"])
def test_status_outside_phase_text_does_not_override_inline_status(runtime, tmp_path, example):
plan(tmp_path, "pending", "### Phase 1: Work [pending]\n" + example)
assert_counts(listed(runtime, tmp_path), "pending", 0, 1, 0, 1)
def test_empty_plan_has_zero_counts(runtime, tmp_path):
plan(tmp_path, "empty")
assert_counts(listed(runtime, tmp_path), "empty", 0, 0)
@pytest.mark.parametrize("language", ["en", "ar", "de", "es", "zh", "zht"])
def test_real_shipped_templates_have_five_phase_counts(runtime, tmp_path, language):
if language == "en":
source = REPO_ROOT / "templates" / "task_plan.md"
else:
source = (REPO_ROOT / "skills" / "i18n" / f"planning-with-files-{language}"
/ "templates" / "task_plan.md")
plan(tmp_path, f"template-{language}", source.read_text(encoding="utf-8"))
assert_counts(listed(runtime, tmp_path), f"template-{language}", 0, 5, 1, 4)
def test_missing_planning_directory_is_read_only(runtime, tmp_path):
output = listed(runtime, tmp_path)
assert "No planning directory found" in output
assert not (tmp_path / ".planning").exists()
def test_empty_planning_directory_and_pointer_are_read_only(runtime, tmp_path):
planning = tmp_path / ".planning"
planning.mkdir()
pointer = planning / ".active_plan"
pointer.write_bytes(b"")
output = listed(runtime, tmp_path)
assert "No named plans found" in output
assert pointer.read_bytes() == b""
assert sorted(path.name for path in planning.iterdir()) == [".active_plan"]
@pytest.mark.parametrize("pointer_bytes,active", [
(b"alpha" + bytes((13, 10)), True),
(bytes((239, 187, 191)) + b"alpha" + bytes((10,)), True),
(b"missing" + bytes((10,)), False),
(b"../alpha" + bytes((10,)), False), (b"", False),
])
def test_listing_preserves_read_only_pointer(runtime, tmp_path, pointer_bytes, active):
plan_file = plan(tmp_path, "alpha", "### Phase 1: Work [pending]\n")
pointer = tmp_path / ".planning" / ".active_plan"
pointer.write_bytes(pointer_bytes)
before_plan = plan_file.read_bytes()
before_mtime = pointer.stat().st_mtime_ns
original_mode = pointer.stat().st_mode
pointer.chmod(stat.S_IREAD)
try:
output = listed(runtime, tmp_path)
assert ("[active]" in row(output, "alpha")) is active
assert pointer.read_bytes() == pointer_bytes
assert pointer.stat().st_mtime_ns == before_mtime
assert plan_file.read_bytes() == before_plan
finally:
pointer.chmod(original_mode)
def test_listing_does_not_create_a_pointer(runtime, tmp_path):
plan(tmp_path, "alpha")
listed(runtime, tmp_path)
assert not (tmp_path / ".planning" / ".active_plan").exists()
def test_inventory_accepts_safe_slugs_and_requires_a_plan_file(runtime, tmp_path):
accepted = ("Alpha", "9-work", "with.dot", "with_under_score", "_legacy")
rejected = (".hidden", "-option", "space name")
for slug in (*accepted, *rejected):
plan(tmp_path, slug)
(tmp_path / ".planning" / "no-plan").mkdir()
(tmp_path / ".planning" / "file-only").write_text("not a directory")
output = listed(runtime, tmp_path)
for slug in accepted:
assert_counts(output, slug, 0, 0)
for slug in (*rejected, "no-plan", "file-only"):
assert f"- {slug}" not in output
def test_listing_uses_cwd_even_when_a_session_selector_is_set(runtime, tmp_path):
project = tmp_path / "project"
external = tmp_path / "external"
plan(project, "local-plan")
plan(external, "external-plan")
output = listed(runtime, project, env_extra={
"PWF_PLAN_ROOT": str(external), "PLAN_ID": "external-plan",
})
assert_counts(output, "local-plan", 0, 0)
assert "external-plan" not in output
def test_literal_workspace_path_characters(runtime, tmp_path):
project = tmp_path / "workspace [sample]"
plan(project, "alpha")
assert_counts(listed(runtime, project), "alpha", 0, 0)
def make_symlink(link: Path, target: Path, directory: bool) -> None:
try:
link.symlink_to(target, target_is_directory=directory)
except (OSError, NotImplementedError) as error:
pytest.skip(f"filesystem symlinks unavailable: {error}")
def test_directory_symlink_cannot_list_an_external_plan(runtime, tmp_path):
project = tmp_path / "project"
external = tmp_path / "external"
plan(project, "safe")
external_file = plan(external, "private", "### Phase 1 [complete]\n")
link = project / ".planning" / "escape"
make_symlink(link, external_file.parent, directory=True)
output = listed(runtime, project)
assert_counts(output, "safe", 0, 0)
assert "escape" not in output
def test_plan_file_symlink_cannot_read_an_external_plan(runtime, tmp_path):
project = tmp_path / "project"
plan(project, "safe")
external_file = tmp_path / "private.md"
external_file.write_text("### Phase 1 [complete]\n", encoding="utf-8")
escaped_plan = project / ".planning" / "escape"
escaped_plan.mkdir()
make_symlink(escaped_plan / "task_plan.md", external_file, directory=False)
output = listed(runtime, project)
assert_counts(output, "safe", 0, 0)
assert "escape" not in output
def test_pointer_symlink_does_not_mark_a_local_plan_active(runtime, tmp_path):
project = tmp_path / "project"
plan(project, "safe")
external_pointer = tmp_path / "private-pointer"
external_pointer.write_text("safe\n", encoding="utf-8")
make_symlink(project / ".planning" / ".active_plan", external_pointer, directory=False)
output = listed(runtime, project)
assert "[active]" not in row(output, "safe")
assert external_pointer.read_text(encoding="utf-8") == "safe\n"
def test_planning_root_symlink_cannot_list_external_plans(runtime, tmp_path):
project = tmp_path / "project"
project.mkdir()
external_file = plan(tmp_path / "external", "private", "### Phase 1 [complete]\n")
make_symlink(project / ".planning", external_file.parent.parent, directory=True)
output = rejected_root_listing(runtime, project)
assert "private" not in output
assert "1/1" not in output
def make_junction(link: Path, target: Path) -> None:
if os.name != "nt":
pytest.skip("Windows junction regression")
powershell = shutil.which("powershell.exe") or shutil.which("pwsh.exe")
if powershell is None:
pytest.skip("junction setup needs PowerShell")
quote = lambda value: "'" + str(value).replace("'", "''") + "'"
command = (
"$ErrorActionPreference = 'Stop'; "
f"New-Item -ItemType Junction -Path {quote(link)} -Target {quote(target)} | Out-Null"
)
result = subprocess.run(
[powershell, "-NoProfile", "-NonInteractive", "-Command", command],
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=30,
)
assert result.returncode == 0, result.stdout + result.stderr
@pytest.mark.parametrize("link_root", [False, True], ids=["plan-directory", "planning-root"])
def test_windows_junction_cannot_list_external_plans(runtime, tmp_path, link_root):
project = tmp_path / "project"
external_file = plan(tmp_path / "external", "private", "### Phase 1 [complete]\n")
if link_root:
project.mkdir()
link = project / ".planning"
target = external_file.parent.parent
else:
plan(project, "safe")
link = project / ".planning" / "escape"
target = external_file.parent
make_junction(link, target)
try:
output = (rejected_root_listing(runtime, project) if link_root
else listed(runtime, project))
assert "private" not in output
assert "escape" not in output
assert "1/1" not in output
if not link_root:
assert_counts(output, "safe", 0, 0)
finally:
# Remove only the junction created above, never recurse into its target.
link.rmdir()
assert external_file.is_file()
def test_existing_set_and_show_semantics(runtime, tmp_path):
initial = run_selector(runtime, tmp_path)
assert initial.returncode == 0, initial.stderr
assert "No active plan set" in initial.stdout
# Setting an existing directory does not require task_plan.md.
(tmp_path / ".planning" / "selected").mkdir(parents=True)
selected = run_selector(runtime, tmp_path, "selected")
assert selected.returncode == 0, selected.stderr
pointer = tmp_path / ".planning" / ".active_plan"
assert pointer.read_bytes().rstrip(b"\r\n") == b"selected"
shown = run_selector(runtime, tmp_path)
assert shown.returncode == 0, shown.stderr
assert "Active plan: selected" in shown.stdout
pointer.write_bytes(b"stale\n")
stale = run_selector(runtime, tmp_path)
assert stale.returncode == 0, stale.stderr
assert "stale pointer" in stale.stdout
missing = run_selector(runtime, tmp_path, "absent")
assert missing.returncode != 0
assert "not found" in missing.stderr
assert pointer.read_bytes() == b"stale\n"
def test_read_only_pointer_never_reports_success_without_switching(runtime, tmp_path):
plan(tmp_path, "selected")
pointer = tmp_path / ".planning" / ".active_plan"
before = b"previous\n"
pointer.write_bytes(before)
original_mode = pointer.stat().st_mode
pointer.chmod(stat.S_IREAD)
try:
result = run_selector(runtime, tmp_path, "selected")
if result.returncode == 0:
# POSIX can atomically replace a read-only file when its directory
# is writable; Windows may instead refuse. Neither may lie.
assert pointer.read_bytes().rstrip(b"\r\n") == b"selected", result.stdout + result.stderr
assert "Active plan set to: selected" in result.stdout
else:
assert pointer.read_bytes() == before
assert "Active plan set to" not in result.stdout
finally:
pointer.chmod(original_mode)
def test_switching_a_hardlinked_pointer_does_not_overwrite_external_file(runtime, tmp_path):
project = tmp_path / "project"
plan(project, "selected")
external = tmp_path / "external-canary"
before = b"private-file-contents\n"
external.write_bytes(before)
pointer = project / ".planning" / ".active_plan"
try:
os.link(external, pointer)
except (OSError, NotImplementedError) as error:
pytest.skip(f"filesystem hardlinks unavailable: {error}")
result = run_selector(runtime, project, "selected")
assert external.read_bytes() == before, result.stdout + result.stderr
assert result.returncode == 0, result.stdout + result.stderr
assert pointer.read_bytes().rstrip(b"\r\n") == b"selected"
+86
View File
@@ -0,0 +1,86 @@
"""Ensure every applicable install surface ships the standalone listing helpers."""
from __future__ import annotations
import os
import runpy
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
CANONICAL = ROOT / "skills/planning-with-files/scripts"
HELPERS = ("set-active-plan.sh", "set-active-plan.ps1")
IDE_SURFACES = (
".agents", ".codebuddy", ".codex", ".continue", ".cursor", ".factory",
".gemini", ".hermes", ".mastracode", ".opencode", ".pi",
)
SURFACES = (
(".", "scripts"),
("canonical", "skills/planning-with-files/scripts"),
*((ide, f"{ide}/skills/planning-with-files/scripts") for ide in IDE_SURFACES),
*((f"skills/i18n/planning-with-files-{lang}",
f"skills/i18n/planning-with-files-{lang}/scripts")
for lang in ("ar", "de", "es", "zh", "zht")),
)
MANIFESTS = runpy.run_path(str(ROOT / "scripts/sync-ide-folders.py"))["IDE_MANIFESTS"]
RUNTIMES = (
("sh", shutil.which("sh")),
("powershell", shutil.which("powershell.exe") or shutil.which("powershell")),
("pwsh", shutil.which("pwsh.exe") or shutil.which("pwsh")),
)
@pytest.mark.parametrize("manifest_key,relative_scripts", SURFACES)
def test_listing_helpers_are_shipped_and_synced(manifest_key, relative_scripts):
"""An IDE can have a different adapter without losing the listing feature."""
for helper in HELPERS:
shipped = ROOT / relative_scripts / helper
assert shipped.is_file(), f"Missing listing helper: {shipped}"
assert shipped.read_bytes() == (CANONICAL / helper).read_bytes()
if manifest_key != "canonical":
target = MANIFESTS[manifest_key][f"scripts/{helper}"]
assert ROOT / target == shipped
@pytest.mark.parametrize("ide", (".hermes", ".mastracode", ".opencode"))
@pytest.mark.parametrize("runtime", RUNTIMES, ids=lambda runtime: runtime[0])
def test_listing_runs_without_sibling_scripts(ide, runtime, tmp_path):
"""Partial script bundles must remain usable from the project directory."""
name, executable = runtime
if executable is None:
pytest.skip(f"{name} is not installed")
helper = "set-active-plan.sh" if name == "sh" else "set-active-plan.ps1"
install = tmp_path / "installed skill"
install.mkdir()
script = install / helper
shutil.copy2(ROOT / ide / "skills/planning-with-files/scripts" / helper, script)
project = tmp_path / "user project"
plan_dir = project / ".planning/alpha"
plan_dir.mkdir(parents=True)
(plan_dir / "task_plan.md").write_text(
"# Task\n### Phase 1: Review [complete]\n"
"### Phase 2: Verify\n**Status:** pending\n", encoding="utf-8",
)
(project / ".planning/.active_plan").write_text("alpha\n", encoding="utf-8")
if name == "sh":
command = [executable, str(script), "--list"]
else:
command = [
executable, "-NoLogo", "-NoProfile", "-NonInteractive",
"-ExecutionPolicy", "Bypass", "-File", str(script), "-List",
]
env = os.environ.copy()
for key in ("PLAN_ID", "PWF_PLAN_ROOT", "PLANNING_DISABLED"):
env.pop(key, None)
result = subprocess.run(
command, cwd=project, env=env, text=True, encoding="utf-8",
errors="replace", capture_output=True, check=False, timeout=60,
)
assert result.returncode == 0, result.stdout + result.stderr
assert not result.stderr.strip(), result.stderr
assert "alpha" in result.stdout
assert "[active]" in result.stdout
assert "1/2 complete, 0 in_progress, 1 pending" in result.stdout
+41
View File
@@ -124,6 +124,47 @@ class SetActivePlanTests(unittest.TestCase):
self.assertEqual(0, result.returncode, result.stderr)
self.assertIn("2026-01-10-my-task", result.stdout)
def test_list_plans_shows_available_plans_status_and_active_marker(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
planning = root / ".planning"
active = planning / "2026-01-10-active"
other = planning / "2026-01-11-other"
hidden = planning / ".scratch"
active.mkdir(parents=True)
other.mkdir(parents=True)
hidden.mkdir(parents=True)
(active / "task_plan.md").write_text(
"# Active\n\n"
"### Phase 1\n- **Status:** complete\n"
"### Phase 2\n- **Status:** in_progress\n",
encoding="utf-8",
)
(other / "task_plan.md").write_text(
"# Other\n\n"
"### Phase 1 [complete]\n"
"### Phase 2 [pending]\n",
encoding="utf-8",
)
(hidden / "task_plan.md").write_text("# Hidden\n", encoding="utf-8")
(planning / ".active_plan").write_text("2026-01-10-active\n", encoding="utf-8")
result = run_set_active(root, "--list")
self.assertEqual(0, result.returncode, result.stderr)
self.assertIn("Available plans:", result.stdout)
self.assertIn("2026-01-10-active [active]", result.stdout)
self.assertIn("1/2 complete, 1 in_progress, 0 pending", result.stdout)
self.assertIn("2026-01-11-other", result.stdout)
self.assertIn("1/2 complete, 0 in_progress, 1 pending", result.stdout)
self.assertNotIn(".scratch", result.stdout)
def test_list_plans_without_planning_dir_is_clean(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
result = run_set_active(Path(tmp), "--list")
self.assertEqual(0, result.returncode, result.stderr)
self.assertIn("No planning directory found", result.stdout)
def test_sets_active_plan(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
+214
View File
@@ -0,0 +1,214 @@
"""CLI regressions for read-only IDE sync verification on temporary repositories."""
from __future__ import annotations
import os
import runpy
import shutil
import subprocess
import sys
from pathlib import Path
import pytest
SCRIPT_PATH = Path(__file__).resolve().parents[1] / "scripts" / "sync-ide-folders.py"
def seed_manifests(root: Path, manifests, ide_names) -> None:
canonical = root / "skills" / "planning-with-files"
canonical.mkdir(parents=True, exist_ok=True)
for ide in ide_names:
(root / ide).mkdir(parents=True, exist_ok=True)
for key, target in manifests[ide].items():
relative_source = key.split("__extra_")[0]
content = f"fixture: {relative_source}\n".encode("utf-8")
for path in (canonical / relative_source, root / target):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(content)
@pytest.fixture
def sync_repo(tmp_path: Path):
# Exercise the real manifest and CLI, without importing or executing any hooks.
manifests = runpy.run_path(str(SCRIPT_PATH))["IDE_MANIFESTS"]
root = tmp_path / "repo with spaces 計畫"
seed_manifests(root, manifests, (".", ".cursor"))
skill = root / ".cursor" / "skills" / "planning-with-files" / "SKILL.md"
skill.write_bytes(b"IDE-specific skill; do not overwrite\n")
hook = root / ".cursor" / "hooks" / "custom.json"
hook.parent.mkdir()
hook.write_bytes(b'{"custom": true}\n')
return root, root / "skills" / "planning-with-files", manifests
def snapshot(root: Path) -> dict:
# Reads can update atime; compare paths, bytes, permissions and mtimes instead.
result = {}
for path in [root, *root.rglob("*")]:
stat = path.stat()
result[path.relative_to(root).as_posix()] = (
stat.st_mode,
stat.st_mtime_ns,
path.read_bytes() if path.is_file() else None,
)
return result
def run_sync(root: Path, *args: str) -> subprocess.CompletedProcess:
return subprocess.run(
[sys.executable, str(SCRIPT_PATH), *args],
cwd=root,
capture_output=True,
text=True,
encoding="utf-8",
env={**os.environ, "PYTHONIOENCODING": "utf-8"},
timeout=15,
check=False,
)
def run_read_only(root: Path, *args: str) -> subprocess.CompletedProcess:
before = snapshot(root)
result = run_sync(root, *args)
assert snapshot(root) == before, "Read-only command changed the fixture tree"
assert result.stderr == "", result.stderr
return result
@pytest.mark.parametrize("content", [b"canonical\n", b""], ids=["text", "empty-file"])
def test_verify_matching_files_succeeds_without_writes(sync_repo, content) -> None:
root, canonical, manifests = sync_repo
key = "templates/findings.md"
(canonical / key).write_bytes(content)
(root / manifests[".cursor"][key]).write_bytes(content)
result = run_read_only(root, "--verify")
assert result.returncode == 0, result.stdout
assert "All IDE folders are in sync." in result.stdout
@pytest.mark.parametrize("target_present", [True, False], ids=["target-present", "both-missing"])
def test_verify_missing_source_fails_without_writes(sync_repo, target_present) -> None:
root, canonical, manifests = sync_repo
key = "templates/findings.md"
(canonical / key).unlink()
if not target_present:
(root / manifests[".cursor"][key]).unlink()
result = run_read_only(root, "--verify")
assert result.returncode == 1, result.stdout
assert f"MISSING SOURCE: {Path('skills/planning-with-files') / key}" in result.stdout
assert "All IDE folders are in sync." not in result.stdout
cursor_section = result.stdout.split(" .cursor/\n", 1)[1].split("\n\n", 1)[0]
assert "(up to date)" not in cursor_section
assert "Restore the missing canonical files" in result.stdout
def test_verify_shared_missing_source_reports_each_affected_entry(sync_repo) -> None:
root, canonical, manifests = sync_repo
seed_manifests(root, manifests, (".gemini",))
(canonical / "templates/findings.md").unlink()
result = run_read_only(root, "--verify")
assert result.returncode == 1, result.stdout
assert result.stdout.count("MISSING SOURCE:") == 2
assert "2 sync entry/entries" in result.stdout
def test_verify_empty_canonical_tree_does_not_report_success(sync_repo) -> None:
root, canonical, _ = sync_repo
for path in canonical.rglob("*"):
if path.is_file():
path.unlink()
result = run_read_only(root, "--verify")
assert result.returncode == 1, result.stdout
assert "MISSING SOURCE:" in result.stdout
assert "(up to date)" not in result.stdout
assert "All IDE folders are in sync." not in result.stdout
@pytest.mark.parametrize("remove_parent", [False, True], ids=["file", "parent-directory"])
def test_verify_missing_destination_fails_without_recreating_it(sync_repo, remove_parent) -> None:
root, _, manifests = sync_repo
target = root / manifests[".cursor"]["templates/findings.md"]
if remove_parent:
shutil.rmtree(target.parent)
else:
target.unlink()
result = run_read_only(root, "--verify")
assert result.returncode == 1, result.stdout
assert f"MISSING: {target.relative_to(root)}" in result.stdout
assert "DRIFT DETECTED:" in result.stdout
def test_verify_content_drift_fails_without_repairing_it(sync_repo) -> None:
root, _, manifests = sync_repo
target = root / manifests[".cursor"]["templates/findings.md"]
target.write_bytes(b"stale mirror\n")
result = run_read_only(root, "--verify")
assert result.returncode == 1, result.stdout
assert f"DRIFT: {target.relative_to(root)}" in result.stdout
assert "DRIFT DETECTED: 1 file(s) out of sync." in result.stdout
def test_verify_still_skips_absent_ide_directories(sync_repo) -> None:
root, canonical, _ = sync_repo
shutil.rmtree(root / ".cursor")
# Only the absent IDE references this source in the remaining fixture.
(canonical / "templates/findings.md").unlink()
result = run_read_only(root, "--verify")
assert result.returncode == 0, result.stdout
assert "All IDE folders are in sync." in result.stdout
assert not (root / ".cursor").exists()
def test_verify_reports_other_problems_alongside_missing_sources(sync_repo) -> None:
root, canonical, manifests = sync_repo
(canonical / "templates/findings.md").unlink()
missing = Path(manifests[".cursor"]["templates/progress.md"])
drifted = Path(manifests[".cursor"]["templates/task_plan.md"])
(root / missing).unlink()
(root / drifted).write_bytes(b"stale plan template\n")
result = run_read_only(root, "--verify")
assert result.returncode == 1, result.stdout
assert "MISSING SOURCE:" in result.stdout
assert f"MISSING: {missing}" in result.stdout
assert f"DRIFT: {drifted}" in result.stdout
assert "Run 'python scripts/sync-ide-folders.py' to fix." not in result.stdout
def test_verify_with_dry_run_still_rejects_missing_sources(sync_repo) -> None:
root, canonical, _ = sync_repo
(canonical / "templates/findings.md").unlink()
result = run_read_only(root, "--verify", "--dry-run")
assert result.returncode == 1, result.stdout
assert "MISSING SOURCE:" in result.stdout
def test_verify_missing_canonical_directory_keeps_existing_error(sync_repo) -> None:
root, canonical, _ = sync_repo
shutil.rmtree(canonical)
result = run_read_only(root, "--verify")
assert result.returncode == 1, result.stdout
assert "Error: Canonical source not found" in result.stdout
@pytest.mark.parametrize("dry_run", [False, True], ids=["sync", "dry-run"])
def test_non_verify_modes_keep_existing_copy_behavior(sync_repo, dry_run) -> None:
root, canonical, manifests = sync_repo
missing_key, stale_key = "templates/findings.md", "templates/progress.md"
missing = root / manifests[".cursor"][missing_key]
stale = root / manifests[".cursor"][stale_key]
missing.unlink()
stale.write_bytes(b"stale\n")
if dry_run:
result = run_read_only(root, "--dry-run")
assert result.returncode == 0, result.stdout
assert "This was a dry run. No files were modified." in result.stdout
else:
result = run_sync(root)
assert result.returncode == 0, result.stdout + result.stderr
assert missing.read_bytes() == (canonical / missing_key).read_bytes()
assert stale.read_bytes() == (canonical / stale_key).read_bytes()
skill = root / ".cursor" / "skills" / "planning-with-files" / "SKILL.md"
hook = root / ".cursor" / "hooks" / "custom.json"
assert skill.read_bytes() == b"IDE-specific skill; do not overwrite\n"
assert hook.read_bytes() == b'{"custom": true}\n'
assert run_read_only(root, "--verify").returncode == 0