Compare commits

...
35 Commits
Author SHA1 Message Date
OthmanAdi 51c1caa27f release: v3.21.0 2026-09-27 04:43:01 +02:00
OthmanAdi 683d591a79 fix: validate explicit attestation targets 2026-09-27 04:42:31 +02:00
TayfurYldz fca9b04f95 feat: add explicit plan attestation target
Signed-off-by: TayfurYldz <238304586+TayfurYldz@users.noreply.github.com>
2026-09-27 04:42:31 +02:00
TayfurYldz 049943aa08 fix(powershell): retry pointer pre-check races 2026-09-27 04:19:09 +02:00
OthmanAdi 4d24d9a8a2 release: v3.20.8 2026-09-25 17:35:37 +02:00
OthmanAdi 21f25c090f fix: mark the Cursor and Gemini hook scripts executable
Gemini CLI runs every hook command through `bash -c <command>`, and
Cursor's hook documentation requires script hooks to be executable. The
four Gemini hooks and the Cursor pre-tool-use, post-tool-use and stop
hooks were committed as 100644, so in a macOS or Linux clone they failed
with "Permission denied" and did nothing. Mark every .sh under
.cursor/hooks and .gemini/hooks executable, and add a test that checks
the git mode of each script the two manifests run by path.
2026-09-25 17:33:41 +02:00
OthmanAdi b0ee2cdd24 fix(cursor): keep the stop hook silent when every phase is complete
Cursor submits a stop hook's followup_message as the next user message.
When every phase of the plan was complete, stop.sh and stop.ps1 still
returned an "ALL PHASES COMPLETE" followup_message, so each stop in a
project with a finished plan triggered automatic follow-up turns, up to
the manifest's loop_limit of 3. This was live on the Windows route and
would reach macOS and Linux once the shell hooks are executable. Exit
without output instead, as completed plans do on the other routes; an
incomplete plan still asks Cursor to continue. Both branches are tested
under sh, Windows PowerShell and pwsh.
2026-09-25 17:33:41 +02:00
OthmanAdi 4c2b269d30 fix(powershell): keep a successful pointer switch successful
The cleanup after replacement returned failure on the first failed
inspection. With concurrent selectors the pointer is briefly missing
while another writer replaces it, so a call whose own Replace had
already succeeded exited 1 and left its .replace-backup behind: about 1
in 10 calls in an 8-writer stress run on Windows PowerShell 5.1 and
pwsh 7. Because the pointer was already switched, init-session.ps1 then
skipped the gated-mode files for a plan that was active.

Retry transient inspection failures instead of giving up. After a
successful Replace, only delete the owned backup: it holds the
superseded value, so it is never moved back and never gated on the
current pointer, and failing to delete it is a warning, not an error.
The regression test holds the backup open after a successful Replace
and expects exit 0, the new pointer and the warning.
2026-09-25 16:40:55 +02:00
kuei51307-hub 94131052d6 fix(powershell): own pointer replacement backups 2026-09-25 16:40:55 +02:00
OthmanAdi a22e4865c6 test(cursor): read the stale-pointer root plan from sessionStart
test_stale_pointer_falls_through_to_the_root_plan still expected the
plan text on preToolUse output, which now carries only the permission
answer. It failed on ubuntu and macOS CI and on Windows. Assert the root
plan in the sessionStart additional_context and the allow response from
preToolUse instead.
2026-09-25 15:46:18 +02:00
OthmanAdi b3ed5256a5 fix(cursor): isolate the session-start hook's Python
session-start.sh called python3 without -I, so a json.py in the project
directory was imported and executed when a Cursor session started. On
Windows the Microsoft Store python3 alias also made the hook return {}.
Try python3, then python, each with -I -X utf8, and keep the {} response
when neither runs. Cover the isolation with a planted json.py test and a
check that every interpreter call in the hook is isolated; the
failing-Python test now stubs both names.
2026-09-25 15:46:18 +02:00
Som Samantray 605f51e09e fix(cursor): align hooks with current schema 2026-09-25 15:46:18 +02:00
OthmanAdi 4c4d0fc74c fix(gemini): list the registered hook events in SKILL.md metadata
The Gemini skill metadata still named SessionStart, BeforeTool, AfterTool
and BeforeModel after the hooks moved to BeforeAgent and SessionEnd.
List the events .gemini/settings.json registers, and assert in the Gemini
contract test that the metadata line and the settings keys agree.
2026-09-25 15:35:21 +02:00
Shaun Lin 77d56b3d00 test(gemini): keep schema fix fork-safe 2026-09-25 15:35:21 +02:00
Shaun Lin 67bfa9971f fix(gemini): align hook context schema 2026-09-25 15:35:21 +02:00
Maciej MisiurekandClaude Fable 5.1 37d6442d7e check-complete: automatic gate check stays silent when no plan exists
The Stop hook runs check-complete --gate on every turn end. In a cwd with
no task_plan.md it echoed "No task_plan.md found — no active planning
session.", which the Claude Code hook surfaced as a system notice after
every reply in every session that never opted into planning.

The script header already promises that plans stay silent in --gate mode;
this makes the no-plan branch honor it. The explicit report (no --gate,
/status) keeps the notice. Same change in check-complete.ps1, the five
language variants, and the synced IDE copies; regression test in
tests/test_quiet_completion_hooks.py covers both checkers and all three
hook routes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-25 15:28:43 +02:00
OthmanAdi 3b7690eafd release: v3.20.7 2026-09-23 22:13:44 +02:00
OthmanAdi f473fa8930 fix: disclose Pi package planning capabilities 2026-09-23 22:09:00 +02:00
OthmanAdi 3827d3c018 release: v3.20.6 2026-09-23 21:58:59 +02:00
OthmanAdi b7edae42dc fix: retry transient PowerShell pointer inspection 2026-09-23 21:51:09 +02:00
OthmanAdi 10c64d464e fix: compare canonical active plan pointer roots 2026-09-23 21:16:28 +02:00
TayfurYldz a82c5a4588 fix: keep DSH core in sync 2026-09-23 21:16:28 +02:00
TayfurYldz 16b6a452bc fix: replace OpenCode active pointer safely 2026-09-23 21:16:28 +02:00
TayfurYldz abd39f7e9b fix: retry concurrent PowerShell pointer replacement 2026-09-23 20:55:49 +02:00
TayfurYldz 388b591f77 fix: recover PowerShell bracketed project cwd 2026-09-23 20:55:43 +02:00
TayfurYldz 87b2553fa1 test: scope read-only pointer case to Windows 2026-09-23 20:55:38 +02:00
TayfurYldz a3bd42954b fix: reject read-only active plan pointers 2026-09-23 20:55:38 +02:00
TayfurYldz 5165ffe847 fix: replace Hermes active pointer safely 2026-09-23 20:55:22 +02:00
kuei51307-hub f7b9be9d58 fix(locking): claim phase status lock atomically 2026-09-23 20:54:59 +02:00
OthmanAdi 0827eeaecf docs: add official skills.sh install badge 2026-09-22 22:07:58 +02:00
OthmanAdi 09d2254d76 docs: restore ClawHub chart below star history 2026-09-22 21:58:33 +02:00
OthmanAdi 78e5bf7b5b docs: simplify star history to the graph 2026-09-22 21:57:34 +02:00
OthmanAdi 27ab93c15d docs: show full star history below reference dropdown 2026-09-22 21:49:40 +02:00
OthmanAdi 4a40450390 docs: remove workflow caption 2026-09-22 19:51:19 +02:00
OthmanAdi 0bd06d344b docs: refresh README layout and visual story 2026-09-22 17:38:29 +02:00
159 changed files with 6658 additions and 1112 deletions
+1 -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.20.5"
version: "3.21.0"
---
# Planning with Files
@@ -6,16 +6,19 @@
# attested hash, surfacing a "[PLAN TAMPERED]" warning instead.
#
# Resolution:
# 1. $PLAN_ID env var → ./.planning/$PLAN_ID/
# 2. ./.planning/.active_plan
# 3. Newest ./.planning/<dir>/ by mtime
# 4. Current directory when it is .planning/<valid-slug>/
# 5. Legacy ./task_plan.md at project root
# 1. --target root or <plan-id> → root or named plan under $PWF_PLAN_ROOT (default: cwd)
# 2. $PLAN_ID env var → ./.planning/$PLAN_ID/
# 3. ./.planning/.active_plan
# 4. Newest ./.planning/<dir>/ by mtime
# 5. Current directory when it is .planning/<valid-slug>/
# 6. Legacy ./task_plan.md at project root
#
# Usage:
# sh scripts/attest-plan.sh # attest the active plan
# sh scripts/attest-plan.sh --show # print the stored hash
# sh scripts/attest-plan.sh --clear # remove the attestation (re-open the plan)
# sh scripts/attest-plan.sh --target root # attest the legacy root plan
# sh scripts/attest-plan.sh --target <plan-id> # attest one named plan
set -u
@@ -42,6 +45,39 @@ resolve_from_slug_cwd() {
}
resolve_plan_file() {
if [ -n "${target:-}" ]; then
if [ "${target}" = "root" ]; then
target_root="."
if [ -n "${PWF_PLAN_ROOT:-}" ]; then
# Match the shared resolver's supported absolute local pins.
# A valid project-root junction is intentional, so resolve it.
case "${PWF_PLAN_ROOT}" in
\\\\*|//*|[A-Za-z]:[!\\/]*) return 1 ;;
/*|[A-Za-z]:[\\/]*) ;;
*) return 1 ;;
esac
target_root="$(cd "${PWF_PLAN_ROOT}" 2>/dev/null && pwd -P)" || return 1
fi
[ ! -L "${target_root}/task_plan.md" ] || return 1
[ -f "${target_root}/task_plan.md" ] || return 1
printf "%s\n" "${target_root}/task_plan.md"
return 0
fi
slug_is_valid "${target}" || return 1
# Reuse the binding, project-pin and containment rules. A flag replaces
# PLAN_ID for this call only; it does not change the shared pointer.
[ -f "${RESOLVER}" ] || return 1
target_dir="$(PLAN_ID="${target}" sh "${RESOLVER}" 2>/dev/null)" || return 1
[ -n "${target_dir}" ] || return 1
# Use the shell's physical path spelling: native Windows backslashes
# make sha256sum prefix its output with an escaped-filename marker.
target_dir="$(cd "${target_dir}" 2>/dev/null && pwd -P)" || return 1
[ ! -L "${target_dir}/task_plan.md" ] || return 1
[ -f "${target_dir}/task_plan.md" ] || return 1
printf "%s\n" "${target_dir}/task_plan.md"
return 0
fi
plan_dir=""
if [ -f "${RESOLVER}" ]; then
plan_dir="$(sh "${RESOLVER}" 2>/dev/null)"
@@ -80,7 +116,9 @@ resolve_plan_file() {
attestation_path_for() {
plan_file="$1"
plan_dir="$(dirname "${plan_file}")"
if [ "${plan_dir}" = "." ]; then
if [ "${target:-}" = "root" ]; then
printf "%s\n" "${plan_dir}/.plan-attestation"
elif [ "${plan_dir}" = "." ]; then
# Legacy mode: store at project root.
printf "%s\n" "./.plan-attestation"
else
@@ -101,14 +139,21 @@ compute_hash() {
}
mode="attest"
target=""
usage_error() {
printf "Usage: %s [--show|--clear|--target root|<plan-id>]\n" "$0" >&2
exit 2
}
case "${1:-}" in
--show) mode="show" ;;
--clear) mode="clear" ;;
"") mode="attest" ;;
*)
printf "Usage: %s [--show|--clear]\n" "$0" >&2
exit 2
--show) [ "$#" -eq 1 ] || usage_error; mode="show" ;;
--clear) [ "$#" -eq 1 ] || usage_error; mode="clear" ;;
--target)
[ "$#" -eq 2 ] || usage_error
[ -n "$2" ] || usage_error
target="$2"
;;
"") [ "$#" -eq 0 ] || usage_error ;;
*) usage_error ;;
esac
plan_file="$(resolve_plan_file)" || {
@@ -117,7 +162,9 @@ plan_file="$(resolve_plan_file)" || {
# a mistyped PLAN_ID attested a DIFFERENT plan at rc=0, and an operator
# who now sees a generic not-found is likely to go looking for the wrong
# problem. The selectors are bindings, so say which one refused.
if [ -n "${PLAN_ID:-}" ]; then
if [ -n "${target:-}" ]; then
printf "[plan-attest] --target %s did not resolve to a plan. An explicit target is a binding: nothing was attested and no other plan was substituted.\n" "${target}" >&2
elif [ -n "${PLAN_ID:-}" ]; then
printf "[plan-attest] PLAN_ID=%s names no plan directory under .planning. An explicit selector is a binding: nothing was attested and no other plan was substituted.\n" "${PLAN_ID}" >&2
elif [ -n "${PWF_PLAN_ROOT:-}" ]; then
printf "[plan-attest] PWF_PLAN_ROOT=%s did not resolve to a project root holding a plan. An explicit pin is a binding: nothing was attested and no other plan was substituted.\n" "${PWF_PLAN_ROOT}" >&2
@@ -156,6 +203,8 @@ case "${mode}" in
fi
;;
attest)
printf "Plan: %s\n" "${plan_file}"
printf "Attestation: %s\n" "${attestation_file}"
hash_val="$(compute_hash "${plan_file}")" || exit 1
# v2.40: protect the write with an advisory flock when available so
@@ -51,6 +51,8 @@ if ($PlanFile -ne "") {
}
if (-not (Test-Path $PlanFile)) {
# Automatic gate checks have nothing to report without a plan; the explicit report keeps its notice.
if ($Gate) { exit 0 }
Write-Host '[planning-with-files] No task_plan.md found -- no active planning session.'
exit 0
}
+3
View File
@@ -79,6 +79,9 @@ else
fi
if [ ! -f "$PLAN_FILE" ]; then
# Automatic gate checks have nothing to report without a plan (header:
# "plans stay silent in --gate mode"); the explicit report keeps its notice.
[ "$GATE" -eq 1 ] && exit 0
echo "[planning-with-files] No task_plan.md found — no active planning session."
exit 0
fi
@@ -18,6 +18,27 @@ param(
[switch]$Gated
)
# Windows PowerShell 5.1 can silently relocate a -File invocation when the
# inherited cwd contains wildcard characters such as [ or ]. Recover the
# physical cwd for a direct invocation before any project-relative paths are
# resolved.
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)) {
Set-Location -LiteralPath ([Environment]::CurrentDirectory)
}
break
}
}
}
$DATE = Get-Date -Format "yyyy-MM-dd"
# Resolve v3 opt-in mode. -Gated implies autonomous and is the stronger marker.
@@ -331,13 +352,39 @@ if ($UsePlanDir) {
# Activate the named plan only after all three planning files are ready.
# This matches init-session.sh and prevents a failed initialization from
# leaving .active_plan pointed at a partial plan directory.
$global:LASTEXITCODE = 0
try {
& $PlanSelector $PlanId *> $null
} catch {
$global:LASTEXITCODE = 1
$pointerSet = $false
for ($attempt = 1; $attempt -le 5; $attempt++) {
$global:LASTEXITCODE = 0
try {
$selectorResult = & $PlanSelector $PlanId 2>&1
} catch {
$selectorResult = $_
$global:LASTEXITCODE = 1
}
if ($LASTEXITCODE -eq 0) {
$pointerSet = $true
break
}
# Another writer can replace the pointer between the selector's
# Get-Item and final-path check. Retry only that transient result.
$transientPointerRace = $selectorResult -is [System.Management.Automation.ErrorRecord] -and
$selectorResult.Exception.Message -in @(
'Error: could not set the active plan pointer: the active plan pointer became unsafe during replacement',
'Error: the active plan pointer must be a regular file within the project.'
)
if ($attempt -eq 5 -or -not $transientPointerRace) {
break
}
Start-Sleep -Milliseconds 50
$global:LASTEXITCODE = 0
try {
& $PlanSelector -VerifyRoot *> $null
} catch {
$global:LASTEXITCODE = 1
}
if ($LASTEXITCODE -ne 0) { break }
}
if ($LASTEXITCODE -ne 0) {
if (-not $pointerSet) {
Write-Error "Error: could not safely update the active plan pointer at $(Join-Path $PlanningRoot '.active_plan')."
exit 1
}
@@ -7,9 +7,10 @@
The ONLY sanctioned concurrent-safe writer of task_plan.md status lines. The
orchestrator owns task_plan.md; workers NEVER edit it directly. The edit is
a read-modify-write under the portable
<plan-dir>\.pwf-locks\phase-status.lock directory lock, with an atomic
temp-file + move swap so a torn write can never leave a half-rewritten plan
on disk (architecture C4).
<plan-dir>\.pwf-locks\phase-status.lock namespace. Ownership is granted by
atomically creating its .owner file, rather than trusting an external mkdir
exit status. The plan rewrite uses an atomic temp-file + move swap so a torn
write can never leave a half-rewritten plan on disk (architecture C4).
Editing task_plan.md changes its SHA, so the orchestrator must re-attest at
phase boundaries (see attest-plan.ps1).
@@ -102,18 +103,44 @@ function Enter-PwfDirectoryLock {
$wait = [Diagnostics.Stopwatch]::StartNew()
while ($wait.Elapsed.TotalSeconds -lt 5) {
$createdByUs = $false
try {
New-Item -Path $LockDir -ItemType Directory -ErrorAction Stop | Out-Null
$createdByUs = $true
[System.IO.File]::WriteAllText($ownerFile, $token + "`n", $utf8NoBom)
[void][System.IO.Directory]::CreateDirectory($LockDir)
} catch {
Start-Sleep -Milliseconds 100
continue
}
$stream = $null
$createdOwner = $false
$claimed = $false
try {
# FileMode.CreateNew is the cross-runtime ownership decision. It
# fails atomically when either the shell or PowerShell writer has
# already claimed .owner, even if both observed mkdir success.
$stream = [System.IO.File]::Open(
$ownerFile,
[System.IO.FileMode]::CreateNew,
[System.IO.FileAccess]::Write,
[System.IO.FileShare]::None
)
$createdOwner = $true
$bytes = $utf8NoBom.GetBytes($token + "`n")
$stream.Write($bytes, 0, $bytes.Length)
$stream.Flush()
$claimed = $true
return [PSCustomObject]@{
Directory = $LockDir
OwnerFile = $ownerFile
Token = $token
}
} catch {
if ($createdByUs) {
# Existing ownership is the normal contention path. Other failures
# remain fail-closed and are retried only within the bounded wait.
} finally {
if ($stream) {
$stream.Dispose()
}
if ($createdOwner -and -not $claimed) {
try {
if ([System.IO.File]::Exists($ownerFile)) {
$ownerValue = [System.IO.File]::ReadAllText($ownerFile).Trim()
@@ -121,13 +148,12 @@ function Enter-PwfDirectoryLock {
[System.IO.File]::Delete($ownerFile)
}
}
[System.IO.Directory]::Delete($LockDir, $false)
} catch {
# Leave any directory we cannot prove is still ours intact.
# Leave any owner file we cannot prove is still ours intact.
}
}
Start-Sleep -Milliseconds 100
}
Start-Sleep -Milliseconds 100
}
return $null
@@ -174,7 +200,7 @@ $planDir = Split-Path -Parent $planFile
$lockRoot = Join-Path $planDir ".pwf-locks"
$lockDir = Join-Path $lockRoot "phase-status.lock"
# Atomic directory creation is the common lock primitive used by both the sh
# Exclusive .owner creation is the common lock primitive used by both the sh
# and PowerShell implementations. Failure to acquire within about five seconds
# is fail-closed: no plan read/rewrite is attempted.
$lock = Enter-PwfDirectoryLock -LockRoot $lockRoot -LockDir $lockDir
@@ -4,9 +4,12 @@
# This is the ONLY sanctioned concurrent-safe writer of task_plan.md status
# lines. The orchestrator owns task_plan.md; workers NEVER edit it directly.
# All status edits go through this read-modify-write under the portable
# <plan-dir>/.pwf-locks/phase-status.lock directory lock, with an atomic
# temp-file + mv swap so a torn write can never leave a half-rewritten plan on
# disk (architecture C4).
# <plan-dir>/.pwf-locks/phase-status.lock namespace. Lock ownership is the
# atomic no-clobber creation of its .owner file; the directory's mkdir exit
# status is only provisional because some Windows-native implementations can
# report success to two simultaneous creators. The plan rewrite still uses an
# atomic temp-file + mv swap so a torn write can never leave a half-rewritten
# plan on disk (architecture C4).
#
# Note: editing task_plan.md changes its SHA, so the orchestrator must
# re-attest at phase boundaries (see attest-plan.sh).
@@ -106,6 +109,22 @@ release_lock() {
LOCK_ACQUIRED=0
}
claim_lock_owner() {
owner_file="${LOCK_DIR}/.owner"
# POSIX noclobber makes this redirection an exclusive create. This is the
# actual ownership decision: a Windows-native mkdir may report success
# after another process won the directory-creation race (issue #281).
if (
umask 077
set -C
printf '%s\n' "${LOCK_TOKEN}" > "${owner_file}"
) 2>/dev/null; then
LOCK_ACQUIRED=1
return 0
fi
return 1
}
acquire_lock() {
mkdir -p "${LOCK_ROOT}" 2>/dev/null || {
printf "[phase-status] Cannot create lock root %s.\n" "${LOCK_ROOT}" >&2
@@ -114,7 +133,13 @@ acquire_lock() {
LOCK_TOKEN="phase-status-$$-$(date +%s 2>/dev/null || printf 0)"
started_at="$(date +%s 2>/dev/null || printf 0)"
attempts=0
while ! mkdir "${LOCK_DIR}" 2>/dev/null; do
while :; do
# Directory creation is intentionally idempotent. The exclusive owner
# file below, not this external utility's exit status, grants the lock.
mkdir "${LOCK_DIR}" 2>/dev/null || true
if [ -d "${LOCK_DIR}" ] && claim_lock_owner; then
return 0
fi
attempts=$((attempts + 1))
now="$(date +%s 2>/dev/null || printf 0)"
if { [ "${started_at}" -gt 0 ] 2>/dev/null \
@@ -125,13 +150,6 @@ acquire_lock() {
fi
sleep 0.1
done
if ! printf '%s\n' "${LOCK_TOKEN}" > "${LOCK_DIR}/.owner" 2>/dev/null; then
rmdir "${LOCK_DIR}" 2>/dev/null || true
printf "[phase-status] Cannot record lock ownership in %s.\n" "${LOCK_DIR}" >&2
return 1
fi
LOCK_ACQUIRED=1
return 0
}
trap 'release_lock' EXIT
@@ -128,9 +128,58 @@ function Test-SafeActiveFile {
$linked = ([string]$item.LinkType) -in @('SymbolicLink', 'Junction')
return -not $item.PSIsContainer -and
($AllowLink -or -not $linked) -and
($AllowLink -or -not $item.IsReadOnly) -and
(Test-WithinRoot $ActiveFile)
}
function Test-SafeOwnedBackupFile {
param([string]$Path)
$item = Get-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue
if (-not $item) { return $false }
$linked = ([string]$item.LinkType) -in @('SymbolicLink', 'Junction')
return -not $item.PSIsContainer -and -not $linked -and (Test-WithinRoot $Path)
}
function Resolve-OwnedPointerBackup {
param([string]$BackupFile, [switch]$ReplaceSucceeded)
# ReplaceFile can move the old destination before failing to move the new
# file. A caller-named backup makes that intermediate state attributable to
# this invocation, so recover or delete only this GUID path. Never glob for
# ~RF*.TMP: a concurrent selector may own those files.
$maxAttempts = 20
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
$backupItem = Get-Item -LiteralPath $BackupFile -Force -ErrorAction SilentlyContinue
if (-not $backupItem) { return $true }
# A failed inspection is transient under concurrent writers: retry.
if (-not (Test-SafeOwnedBackupFile $BackupFile)) { Start-Sleep -Milliseconds 25; continue }
$currentActive = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
# After our own successful replacement the backup holds a superseded
# value: only delete it, never move it back over a newer pointer.
if ($ReplaceSucceeded -or $currentActive) {
if (-not $ReplaceSucceeded -and -not (Test-SafeActiveFile)) { Start-Sleep -Milliseconds 25; continue }
try {
[IO.File]::Delete($BackupFile)
return $true
} catch {
if ($attempt -eq $maxAttempts) { return $false }
}
} else {
try {
[IO.File]::Move($BackupFile, $ActiveFile)
return $true
} catch {
# Another selector may have recreated the pointer between the
# absence check and Move. Reinspect before deciding whether the
# owned backup is now redundant.
if ($attempt -eq $maxAttempts) { return $false }
}
}
Start-Sleep -Milliseconds 25
}
return $false
}
function Get-CurrentActivePlan {
if (-not (Test-SafeActiveFile -AllowLink)) { return "" }
try {
@@ -305,9 +354,16 @@ if ($activeItem -and -not (Test-SafeActiveFile)) {
$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')
# file and caller-owned backup live beside the pointer, keeping replacement on
# the same filesystem. Naming the backup prevents ReplaceFile from inventing
# an unowned ~RF*.TMP path if its final rename fails.
$operationId = [guid]::NewGuid().ToString('N')
$tempFile = Join-Path $PlanRoot ('.active_plan.' + $operationId + '.tmp')
$backupFile = Join-Path $PlanRoot ('.active_plan.' + $operationId + '.replace-backup')
$createdTemp = $false
$failureMessage = ""
$backupResolved = $true
$replaced = $false
try {
$stream = [IO.File]::Open($tempFile, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
$createdTemp = $true
@@ -316,19 +372,48 @@ try {
$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)
$maxAttempts = 20
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
try {
$currentActive = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
if ($currentActive) {
if (-not (Test-SafeActiveFile)) {
throw [InvalidOperationException]::new("the active plan pointer became unsafe during replacement")
}
[IO.File]::Replace($tempFile, $ActiveFile, $backupFile)
} else {
[IO.File]::Move($tempFile, $ActiveFile)
}
$replaced = $true
break
} catch [IO.IOException] {
if (-not (Resolve-OwnedPointerBackup -BackupFile $backupFile)) {
throw [IO.IOException]::new("could not safely recover the active plan pointer after replacement failure")
}
if ($attempt -eq $maxAttempts) { throw }
Start-Sleep -Milliseconds 25
}
}
} catch {
Write-Error "Error: could not set the active plan pointer: $($_.Exception.Message)"
exit 1
$failureMessage = $_.Exception.Message
} finally {
if (-not (Resolve-OwnedPointerBackup -BackupFile $backupFile -ReplaceSucceeded:$replaced)) {
# The pointer was written; a leftover backup is clutter, not failure.
if ($replaced) { Write-Warning "could not remove the replacement backup $backupFile" }
else { $backupResolved = $false }
}
if ($createdTemp -and (Test-Path -LiteralPath $tempFile)) {
Remove-Item -LiteralPath $tempFile -Force -ErrorAction SilentlyContinue
}
}
if (-not $backupResolved) {
Write-Error "Error: could not safely recover the active plan pointer backup."
exit 1
}
if ($failureMessage) {
Write-Error "Error: could not set the active plan pointer: $failureMessage"
exit 1
}
Write-Output "Active plan set to: $PlanId"
Write-Output "Path: $PlanDir"
Write-Output ""
+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.20.5"
"version": "3.21.0"
}
]
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "planning-with-files",
"version": "3.20.5",
"version": "3.21.0",
"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.20.5"
version: "3.21.0"
---
@@ -6,16 +6,19 @@
# attested hash, surfacing a "[PLAN TAMPERED]" warning instead.
#
# Resolution:
# 1. $PLAN_ID env var → ./.planning/$PLAN_ID/
# 2. ./.planning/.active_plan
# 3. Newest ./.planning/<dir>/ by mtime
# 4. Current directory when it is .planning/<valid-slug>/
# 5. Legacy ./task_plan.md at project root
# 1. --target root or <plan-id> → root or named plan under $PWF_PLAN_ROOT (default: cwd)
# 2. $PLAN_ID env var → ./.planning/$PLAN_ID/
# 3. ./.planning/.active_plan
# 4. Newest ./.planning/<dir>/ by mtime
# 5. Current directory when it is .planning/<valid-slug>/
# 6. Legacy ./task_plan.md at project root
#
# Usage:
# sh scripts/attest-plan.sh # attest the active plan
# sh scripts/attest-plan.sh --show # print the stored hash
# sh scripts/attest-plan.sh --clear # remove the attestation (re-open the plan)
# sh scripts/attest-plan.sh --target root # attest the legacy root plan
# sh scripts/attest-plan.sh --target <plan-id> # attest one named plan
set -u
@@ -42,6 +45,39 @@ resolve_from_slug_cwd() {
}
resolve_plan_file() {
if [ -n "${target:-}" ]; then
if [ "${target}" = "root" ]; then
target_root="."
if [ -n "${PWF_PLAN_ROOT:-}" ]; then
# Match the shared resolver's supported absolute local pins.
# A valid project-root junction is intentional, so resolve it.
case "${PWF_PLAN_ROOT}" in
\\\\*|//*|[A-Za-z]:[!\\/]*) return 1 ;;
/*|[A-Za-z]:[\\/]*) ;;
*) return 1 ;;
esac
target_root="$(cd "${PWF_PLAN_ROOT}" 2>/dev/null && pwd -P)" || return 1
fi
[ ! -L "${target_root}/task_plan.md" ] || return 1
[ -f "${target_root}/task_plan.md" ] || return 1
printf "%s\n" "${target_root}/task_plan.md"
return 0
fi
slug_is_valid "${target}" || return 1
# Reuse the binding, project-pin and containment rules. A flag replaces
# PLAN_ID for this call only; it does not change the shared pointer.
[ -f "${RESOLVER}" ] || return 1
target_dir="$(PLAN_ID="${target}" sh "${RESOLVER}" 2>/dev/null)" || return 1
[ -n "${target_dir}" ] || return 1
# Use the shell's physical path spelling: native Windows backslashes
# make sha256sum prefix its output with an escaped-filename marker.
target_dir="$(cd "${target_dir}" 2>/dev/null && pwd -P)" || return 1
[ ! -L "${target_dir}/task_plan.md" ] || return 1
[ -f "${target_dir}/task_plan.md" ] || return 1
printf "%s\n" "${target_dir}/task_plan.md"
return 0
fi
plan_dir=""
if [ -f "${RESOLVER}" ]; then
plan_dir="$(sh "${RESOLVER}" 2>/dev/null)"
@@ -80,7 +116,9 @@ resolve_plan_file() {
attestation_path_for() {
plan_file="$1"
plan_dir="$(dirname "${plan_file}")"
if [ "${plan_dir}" = "." ]; then
if [ "${target:-}" = "root" ]; then
printf "%s\n" "${plan_dir}/.plan-attestation"
elif [ "${plan_dir}" = "." ]; then
# Legacy mode: store at project root.
printf "%s\n" "./.plan-attestation"
else
@@ -101,14 +139,21 @@ compute_hash() {
}
mode="attest"
target=""
usage_error() {
printf "Usage: %s [--show|--clear|--target root|<plan-id>]\n" "$0" >&2
exit 2
}
case "${1:-}" in
--show) mode="show" ;;
--clear) mode="clear" ;;
"") mode="attest" ;;
*)
printf "Usage: %s [--show|--clear]\n" "$0" >&2
exit 2
--show) [ "$#" -eq 1 ] || usage_error; mode="show" ;;
--clear) [ "$#" -eq 1 ] || usage_error; mode="clear" ;;
--target)
[ "$#" -eq 2 ] || usage_error
[ -n "$2" ] || usage_error
target="$2"
;;
"") [ "$#" -eq 0 ] || usage_error ;;
*) usage_error ;;
esac
plan_file="$(resolve_plan_file)" || {
@@ -117,7 +162,9 @@ plan_file="$(resolve_plan_file)" || {
# a mistyped PLAN_ID attested a DIFFERENT plan at rc=0, and an operator
# who now sees a generic not-found is likely to go looking for the wrong
# problem. The selectors are bindings, so say which one refused.
if [ -n "${PLAN_ID:-}" ]; then
if [ -n "${target:-}" ]; then
printf "[plan-attest] --target %s did not resolve to a plan. An explicit target is a binding: nothing was attested and no other plan was substituted.\n" "${target}" >&2
elif [ -n "${PLAN_ID:-}" ]; then
printf "[plan-attest] PLAN_ID=%s names no plan directory under .planning. An explicit selector is a binding: nothing was attested and no other plan was substituted.\n" "${PLAN_ID}" >&2
elif [ -n "${PWF_PLAN_ROOT:-}" ]; then
printf "[plan-attest] PWF_PLAN_ROOT=%s did not resolve to a project root holding a plan. An explicit pin is a binding: nothing was attested and no other plan was substituted.\n" "${PWF_PLAN_ROOT}" >&2
@@ -156,6 +203,8 @@ case "${mode}" in
fi
;;
attest)
printf "Plan: %s\n" "${plan_file}"
printf "Attestation: %s\n" "${attestation_file}"
hash_val="$(compute_hash "${plan_file}")" || exit 1
# v2.40: protect the write with an advisory flock when available so
@@ -51,6 +51,8 @@ if ($PlanFile -ne "") {
}
if (-not (Test-Path $PlanFile)) {
# Automatic gate checks have nothing to report without a plan; the explicit report keeps its notice.
if ($Gate) { exit 0 }
Write-Host '[planning-with-files] No task_plan.md found -- no active planning session.'
exit 0
}
@@ -79,6 +79,9 @@ else
fi
if [ ! -f "$PLAN_FILE" ]; then
# Automatic gate checks have nothing to report without a plan (header:
# "plans stay silent in --gate mode"); the explicit report keeps its notice.
[ "$GATE" -eq 1 ] && exit 0
echo "[planning-with-files] No task_plan.md found — no active planning session."
exit 0
fi
@@ -18,6 +18,27 @@ param(
[switch]$Gated
)
# Windows PowerShell 5.1 can silently relocate a -File invocation when the
# inherited cwd contains wildcard characters such as [ or ]. Recover the
# physical cwd for a direct invocation before any project-relative paths are
# resolved.
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)) {
Set-Location -LiteralPath ([Environment]::CurrentDirectory)
}
break
}
}
}
$DATE = Get-Date -Format "yyyy-MM-dd"
# Resolve v3 opt-in mode. -Gated implies autonomous and is the stronger marker.
@@ -331,13 +352,39 @@ if ($UsePlanDir) {
# Activate the named plan only after all three planning files are ready.
# This matches init-session.sh and prevents a failed initialization from
# leaving .active_plan pointed at a partial plan directory.
$global:LASTEXITCODE = 0
try {
& $PlanSelector $PlanId *> $null
} catch {
$global:LASTEXITCODE = 1
$pointerSet = $false
for ($attempt = 1; $attempt -le 5; $attempt++) {
$global:LASTEXITCODE = 0
try {
$selectorResult = & $PlanSelector $PlanId 2>&1
} catch {
$selectorResult = $_
$global:LASTEXITCODE = 1
}
if ($LASTEXITCODE -eq 0) {
$pointerSet = $true
break
}
# Another writer can replace the pointer between the selector's
# Get-Item and final-path check. Retry only that transient result.
$transientPointerRace = $selectorResult -is [System.Management.Automation.ErrorRecord] -and
$selectorResult.Exception.Message -in @(
'Error: could not set the active plan pointer: the active plan pointer became unsafe during replacement',
'Error: the active plan pointer must be a regular file within the project.'
)
if ($attempt -eq 5 -or -not $transientPointerRace) {
break
}
Start-Sleep -Milliseconds 50
$global:LASTEXITCODE = 0
try {
& $PlanSelector -VerifyRoot *> $null
} catch {
$global:LASTEXITCODE = 1
}
if ($LASTEXITCODE -ne 0) { break }
}
if ($LASTEXITCODE -ne 0) {
if (-not $pointerSet) {
Write-Error "Error: could not safely update the active plan pointer at $(Join-Path $PlanningRoot '.active_plan')."
exit 1
}
@@ -128,9 +128,58 @@ function Test-SafeActiveFile {
$linked = ([string]$item.LinkType) -in @('SymbolicLink', 'Junction')
return -not $item.PSIsContainer -and
($AllowLink -or -not $linked) -and
($AllowLink -or -not $item.IsReadOnly) -and
(Test-WithinRoot $ActiveFile)
}
function Test-SafeOwnedBackupFile {
param([string]$Path)
$item = Get-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue
if (-not $item) { return $false }
$linked = ([string]$item.LinkType) -in @('SymbolicLink', 'Junction')
return -not $item.PSIsContainer -and -not $linked -and (Test-WithinRoot $Path)
}
function Resolve-OwnedPointerBackup {
param([string]$BackupFile, [switch]$ReplaceSucceeded)
# ReplaceFile can move the old destination before failing to move the new
# file. A caller-named backup makes that intermediate state attributable to
# this invocation, so recover or delete only this GUID path. Never glob for
# ~RF*.TMP: a concurrent selector may own those files.
$maxAttempts = 20
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
$backupItem = Get-Item -LiteralPath $BackupFile -Force -ErrorAction SilentlyContinue
if (-not $backupItem) { return $true }
# A failed inspection is transient under concurrent writers: retry.
if (-not (Test-SafeOwnedBackupFile $BackupFile)) { Start-Sleep -Milliseconds 25; continue }
$currentActive = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
# After our own successful replacement the backup holds a superseded
# value: only delete it, never move it back over a newer pointer.
if ($ReplaceSucceeded -or $currentActive) {
if (-not $ReplaceSucceeded -and -not (Test-SafeActiveFile)) { Start-Sleep -Milliseconds 25; continue }
try {
[IO.File]::Delete($BackupFile)
return $true
} catch {
if ($attempt -eq $maxAttempts) { return $false }
}
} else {
try {
[IO.File]::Move($BackupFile, $ActiveFile)
return $true
} catch {
# Another selector may have recreated the pointer between the
# absence check and Move. Reinspect before deciding whether the
# owned backup is now redundant.
if ($attempt -eq $maxAttempts) { return $false }
}
}
Start-Sleep -Milliseconds 25
}
return $false
}
function Get-CurrentActivePlan {
if (-not (Test-SafeActiveFile -AllowLink)) { return "" }
try {
@@ -305,9 +354,16 @@ if ($activeItem -and -not (Test-SafeActiveFile)) {
$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')
# file and caller-owned backup live beside the pointer, keeping replacement on
# the same filesystem. Naming the backup prevents ReplaceFile from inventing
# an unowned ~RF*.TMP path if its final rename fails.
$operationId = [guid]::NewGuid().ToString('N')
$tempFile = Join-Path $PlanRoot ('.active_plan.' + $operationId + '.tmp')
$backupFile = Join-Path $PlanRoot ('.active_plan.' + $operationId + '.replace-backup')
$createdTemp = $false
$failureMessage = ""
$backupResolved = $true
$replaced = $false
try {
$stream = [IO.File]::Open($tempFile, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
$createdTemp = $true
@@ -316,19 +372,48 @@ try {
$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)
$maxAttempts = 20
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
try {
$currentActive = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
if ($currentActive) {
if (-not (Test-SafeActiveFile)) {
throw [InvalidOperationException]::new("the active plan pointer became unsafe during replacement")
}
[IO.File]::Replace($tempFile, $ActiveFile, $backupFile)
} else {
[IO.File]::Move($tempFile, $ActiveFile)
}
$replaced = $true
break
} catch [IO.IOException] {
if (-not (Resolve-OwnedPointerBackup -BackupFile $backupFile)) {
throw [IO.IOException]::new("could not safely recover the active plan pointer after replacement failure")
}
if ($attempt -eq $maxAttempts) { throw }
Start-Sleep -Milliseconds 25
}
}
} catch {
Write-Error "Error: could not set the active plan pointer: $($_.Exception.Message)"
exit 1
$failureMessage = $_.Exception.Message
} finally {
if (-not (Resolve-OwnedPointerBackup -BackupFile $backupFile -ReplaceSucceeded:$replaced)) {
# The pointer was written; a leftover backup is clutter, not failure.
if ($replaced) { Write-Warning "could not remove the replacement backup $backupFile" }
else { $backupResolved = $false }
}
if ($createdTemp -and (Test-Path -LiteralPath $tempFile)) {
Remove-Item -LiteralPath $tempFile -Force -ErrorAction SilentlyContinue
}
}
if (-not $backupResolved) {
Write-Error "Error: could not safely recover the active plan pointer backup."
exit 1
}
if ($failureMessage) {
Write-Error "Error: could not set the active plan pointer: $failureMessage"
exit 1
}
Write-Output "Active plan set to: $PlanId"
Write-Output "Path: $PlanDir"
Write-Output ""
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "planning-with-files",
"version": "3.20.5",
"version": "3.21.0",
"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",
+1 -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.20.5"
version: "3.21.0"
---
@@ -6,16 +6,19 @@
# attested hash, surfacing a "[PLAN TAMPERED]" warning instead.
#
# Resolution:
# 1. $PLAN_ID env var → ./.planning/$PLAN_ID/
# 2. ./.planning/.active_plan
# 3. Newest ./.planning/<dir>/ by mtime
# 4. Current directory when it is .planning/<valid-slug>/
# 5. Legacy ./task_plan.md at project root
# 1. --target root or <plan-id> → root or named plan under $PWF_PLAN_ROOT (default: cwd)
# 2. $PLAN_ID env var → ./.planning/$PLAN_ID/
# 3. ./.planning/.active_plan
# 4. Newest ./.planning/<dir>/ by mtime
# 5. Current directory when it is .planning/<valid-slug>/
# 6. Legacy ./task_plan.md at project root
#
# Usage:
# sh scripts/attest-plan.sh # attest the active plan
# sh scripts/attest-plan.sh --show # print the stored hash
# sh scripts/attest-plan.sh --clear # remove the attestation (re-open the plan)
# sh scripts/attest-plan.sh --target root # attest the legacy root plan
# sh scripts/attest-plan.sh --target <plan-id> # attest one named plan
set -u
@@ -42,6 +45,39 @@ resolve_from_slug_cwd() {
}
resolve_plan_file() {
if [ -n "${target:-}" ]; then
if [ "${target}" = "root" ]; then
target_root="."
if [ -n "${PWF_PLAN_ROOT:-}" ]; then
# Match the shared resolver's supported absolute local pins.
# A valid project-root junction is intentional, so resolve it.
case "${PWF_PLAN_ROOT}" in
\\\\*|//*|[A-Za-z]:[!\\/]*) return 1 ;;
/*|[A-Za-z]:[\\/]*) ;;
*) return 1 ;;
esac
target_root="$(cd "${PWF_PLAN_ROOT}" 2>/dev/null && pwd -P)" || return 1
fi
[ ! -L "${target_root}/task_plan.md" ] || return 1
[ -f "${target_root}/task_plan.md" ] || return 1
printf "%s\n" "${target_root}/task_plan.md"
return 0
fi
slug_is_valid "${target}" || return 1
# Reuse the binding, project-pin and containment rules. A flag replaces
# PLAN_ID for this call only; it does not change the shared pointer.
[ -f "${RESOLVER}" ] || return 1
target_dir="$(PLAN_ID="${target}" sh "${RESOLVER}" 2>/dev/null)" || return 1
[ -n "${target_dir}" ] || return 1
# Use the shell's physical path spelling: native Windows backslashes
# make sha256sum prefix its output with an escaped-filename marker.
target_dir="$(cd "${target_dir}" 2>/dev/null && pwd -P)" || return 1
[ ! -L "${target_dir}/task_plan.md" ] || return 1
[ -f "${target_dir}/task_plan.md" ] || return 1
printf "%s\n" "${target_dir}/task_plan.md"
return 0
fi
plan_dir=""
if [ -f "${RESOLVER}" ]; then
plan_dir="$(sh "${RESOLVER}" 2>/dev/null)"
@@ -80,7 +116,9 @@ resolve_plan_file() {
attestation_path_for() {
plan_file="$1"
plan_dir="$(dirname "${plan_file}")"
if [ "${plan_dir}" = "." ]; then
if [ "${target:-}" = "root" ]; then
printf "%s\n" "${plan_dir}/.plan-attestation"
elif [ "${plan_dir}" = "." ]; then
# Legacy mode: store at project root.
printf "%s\n" "./.plan-attestation"
else
@@ -101,14 +139,21 @@ compute_hash() {
}
mode="attest"
target=""
usage_error() {
printf "Usage: %s [--show|--clear|--target root|<plan-id>]\n" "$0" >&2
exit 2
}
case "${1:-}" in
--show) mode="show" ;;
--clear) mode="clear" ;;
"") mode="attest" ;;
*)
printf "Usage: %s [--show|--clear]\n" "$0" >&2
exit 2
--show) [ "$#" -eq 1 ] || usage_error; mode="show" ;;
--clear) [ "$#" -eq 1 ] || usage_error; mode="clear" ;;
--target)
[ "$#" -eq 2 ] || usage_error
[ -n "$2" ] || usage_error
target="$2"
;;
"") [ "$#" -eq 0 ] || usage_error ;;
*) usage_error ;;
esac
plan_file="$(resolve_plan_file)" || {
@@ -117,7 +162,9 @@ plan_file="$(resolve_plan_file)" || {
# a mistyped PLAN_ID attested a DIFFERENT plan at rc=0, and an operator
# who now sees a generic not-found is likely to go looking for the wrong
# problem. The selectors are bindings, so say which one refused.
if [ -n "${PLAN_ID:-}" ]; then
if [ -n "${target:-}" ]; then
printf "[plan-attest] --target %s did not resolve to a plan. An explicit target is a binding: nothing was attested and no other plan was substituted.\n" "${target}" >&2
elif [ -n "${PLAN_ID:-}" ]; then
printf "[plan-attest] PLAN_ID=%s names no plan directory under .planning. An explicit selector is a binding: nothing was attested and no other plan was substituted.\n" "${PLAN_ID}" >&2
elif [ -n "${PWF_PLAN_ROOT:-}" ]; then
printf "[plan-attest] PWF_PLAN_ROOT=%s did not resolve to a project root holding a plan. An explicit pin is a binding: nothing was attested and no other plan was substituted.\n" "${PWF_PLAN_ROOT}" >&2
@@ -156,6 +203,8 @@ case "${mode}" in
fi
;;
attest)
printf "Plan: %s\n" "${plan_file}"
printf "Attestation: %s\n" "${attestation_file}"
hash_val="$(compute_hash "${plan_file}")" || exit 1
# v2.40: protect the write with an advisory flock when available so
@@ -51,6 +51,8 @@ if ($PlanFile -ne "") {
}
if (-not (Test-Path $PlanFile)) {
# Automatic gate checks have nothing to report without a plan; the explicit report keeps its notice.
if ($Gate) { exit 0 }
Write-Host '[planning-with-files] No task_plan.md found -- no active planning session.'
exit 0
}
@@ -79,6 +79,9 @@ else
fi
if [ ! -f "$PLAN_FILE" ]; then
# Automatic gate checks have nothing to report without a plan (header:
# "plans stay silent in --gate mode"); the explicit report keeps its notice.
[ "$GATE" -eq 1 ] && exit 0
echo "[planning-with-files] No task_plan.md found — no active planning session."
exit 0
fi
@@ -18,6 +18,27 @@ param(
[switch]$Gated
)
# Windows PowerShell 5.1 can silently relocate a -File invocation when the
# inherited cwd contains wildcard characters such as [ or ]. Recover the
# physical cwd for a direct invocation before any project-relative paths are
# resolved.
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)) {
Set-Location -LiteralPath ([Environment]::CurrentDirectory)
}
break
}
}
}
$DATE = Get-Date -Format "yyyy-MM-dd"
# Resolve v3 opt-in mode. -Gated implies autonomous and is the stronger marker.
@@ -331,13 +352,39 @@ if ($UsePlanDir) {
# Activate the named plan only after all three planning files are ready.
# This matches init-session.sh and prevents a failed initialization from
# leaving .active_plan pointed at a partial plan directory.
$global:LASTEXITCODE = 0
try {
& $PlanSelector $PlanId *> $null
} catch {
$global:LASTEXITCODE = 1
$pointerSet = $false
for ($attempt = 1; $attempt -le 5; $attempt++) {
$global:LASTEXITCODE = 0
try {
$selectorResult = & $PlanSelector $PlanId 2>&1
} catch {
$selectorResult = $_
$global:LASTEXITCODE = 1
}
if ($LASTEXITCODE -eq 0) {
$pointerSet = $true
break
}
# Another writer can replace the pointer between the selector's
# Get-Item and final-path check. Retry only that transient result.
$transientPointerRace = $selectorResult -is [System.Management.Automation.ErrorRecord] -and
$selectorResult.Exception.Message -in @(
'Error: could not set the active plan pointer: the active plan pointer became unsafe during replacement',
'Error: the active plan pointer must be a regular file within the project.'
)
if ($attempt -eq 5 -or -not $transientPointerRace) {
break
}
Start-Sleep -Milliseconds 50
$global:LASTEXITCODE = 0
try {
& $PlanSelector -VerifyRoot *> $null
} catch {
$global:LASTEXITCODE = 1
}
if ($LASTEXITCODE -ne 0) { break }
}
if ($LASTEXITCODE -ne 0) {
if (-not $pointerSet) {
Write-Error "Error: could not safely update the active plan pointer at $(Join-Path $PlanningRoot '.active_plan')."
exit 1
}
@@ -128,9 +128,58 @@ function Test-SafeActiveFile {
$linked = ([string]$item.LinkType) -in @('SymbolicLink', 'Junction')
return -not $item.PSIsContainer -and
($AllowLink -or -not $linked) -and
($AllowLink -or -not $item.IsReadOnly) -and
(Test-WithinRoot $ActiveFile)
}
function Test-SafeOwnedBackupFile {
param([string]$Path)
$item = Get-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue
if (-not $item) { return $false }
$linked = ([string]$item.LinkType) -in @('SymbolicLink', 'Junction')
return -not $item.PSIsContainer -and -not $linked -and (Test-WithinRoot $Path)
}
function Resolve-OwnedPointerBackup {
param([string]$BackupFile, [switch]$ReplaceSucceeded)
# ReplaceFile can move the old destination before failing to move the new
# file. A caller-named backup makes that intermediate state attributable to
# this invocation, so recover or delete only this GUID path. Never glob for
# ~RF*.TMP: a concurrent selector may own those files.
$maxAttempts = 20
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
$backupItem = Get-Item -LiteralPath $BackupFile -Force -ErrorAction SilentlyContinue
if (-not $backupItem) { return $true }
# A failed inspection is transient under concurrent writers: retry.
if (-not (Test-SafeOwnedBackupFile $BackupFile)) { Start-Sleep -Milliseconds 25; continue }
$currentActive = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
# After our own successful replacement the backup holds a superseded
# value: only delete it, never move it back over a newer pointer.
if ($ReplaceSucceeded -or $currentActive) {
if (-not $ReplaceSucceeded -and -not (Test-SafeActiveFile)) { Start-Sleep -Milliseconds 25; continue }
try {
[IO.File]::Delete($BackupFile)
return $true
} catch {
if ($attempt -eq $maxAttempts) { return $false }
}
} else {
try {
[IO.File]::Move($BackupFile, $ActiveFile)
return $true
} catch {
# Another selector may have recreated the pointer between the
# absence check and Move. Reinspect before deciding whether the
# owned backup is now redundant.
if ($attempt -eq $maxAttempts) { return $false }
}
}
Start-Sleep -Milliseconds 25
}
return $false
}
function Get-CurrentActivePlan {
if (-not (Test-SafeActiveFile -AllowLink)) { return "" }
try {
@@ -305,9 +354,16 @@ if ($activeItem -and -not (Test-SafeActiveFile)) {
$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')
# file and caller-owned backup live beside the pointer, keeping replacement on
# the same filesystem. Naming the backup prevents ReplaceFile from inventing
# an unowned ~RF*.TMP path if its final rename fails.
$operationId = [guid]::NewGuid().ToString('N')
$tempFile = Join-Path $PlanRoot ('.active_plan.' + $operationId + '.tmp')
$backupFile = Join-Path $PlanRoot ('.active_plan.' + $operationId + '.replace-backup')
$createdTemp = $false
$failureMessage = ""
$backupResolved = $true
$replaced = $false
try {
$stream = [IO.File]::Open($tempFile, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
$createdTemp = $true
@@ -316,19 +372,48 @@ try {
$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)
$maxAttempts = 20
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
try {
$currentActive = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
if ($currentActive) {
if (-not (Test-SafeActiveFile)) {
throw [InvalidOperationException]::new("the active plan pointer became unsafe during replacement")
}
[IO.File]::Replace($tempFile, $ActiveFile, $backupFile)
} else {
[IO.File]::Move($tempFile, $ActiveFile)
}
$replaced = $true
break
} catch [IO.IOException] {
if (-not (Resolve-OwnedPointerBackup -BackupFile $backupFile)) {
throw [IO.IOException]::new("could not safely recover the active plan pointer after replacement failure")
}
if ($attempt -eq $maxAttempts) { throw }
Start-Sleep -Milliseconds 25
}
}
} catch {
Write-Error "Error: could not set the active plan pointer: $($_.Exception.Message)"
exit 1
$failureMessage = $_.Exception.Message
} finally {
if (-not (Resolve-OwnedPointerBackup -BackupFile $backupFile -ReplaceSucceeded:$replaced)) {
# The pointer was written; a leftover backup is clutter, not failure.
if ($replaced) { Write-Warning "could not remove the replacement backup $backupFile" }
else { $backupResolved = $false }
}
if ($createdTemp -and (Test-Path -LiteralPath $tempFile)) {
Remove-Item -LiteralPath $tempFile -Force -ErrorAction SilentlyContinue
}
}
if (-not $backupResolved) {
Write-Error "Error: could not safely recover the active plan pointer backup."
exit 1
}
if ($failureMessage) {
Write-Error "Error: could not set the active plan pointer: $failureMessage"
exit 1
}
Write-Output "Active plan set to: $PlanId"
Write-Output "Path: $PlanDir"
Write-Output ""
@@ -6,16 +6,19 @@
# attested hash, surfacing a "[PLAN TAMPERED]" warning instead.
#
# Resolution:
# 1. $PLAN_ID env var → ./.planning/$PLAN_ID/
# 2. ./.planning/.active_plan
# 3. Newest ./.planning/<dir>/ by mtime
# 4. Current directory when it is .planning/<valid-slug>/
# 5. Legacy ./task_plan.md at project root
# 1. --target root or <plan-id> → root or named plan under $PWF_PLAN_ROOT (default: cwd)
# 2. $PLAN_ID env var → ./.planning/$PLAN_ID/
# 3. ./.planning/.active_plan
# 4. Newest ./.planning/<dir>/ by mtime
# 5. Current directory when it is .planning/<valid-slug>/
# 6. Legacy ./task_plan.md at project root
#
# Usage:
# sh scripts/attest-plan.sh # attest the active plan
# sh scripts/attest-plan.sh --show # print the stored hash
# sh scripts/attest-plan.sh --clear # remove the attestation (re-open the plan)
# sh scripts/attest-plan.sh --target root # attest the legacy root plan
# sh scripts/attest-plan.sh --target <plan-id> # attest one named plan
set -u
@@ -42,6 +45,39 @@ resolve_from_slug_cwd() {
}
resolve_plan_file() {
if [ -n "${target:-}" ]; then
if [ "${target}" = "root" ]; then
target_root="."
if [ -n "${PWF_PLAN_ROOT:-}" ]; then
# Match the shared resolver's supported absolute local pins.
# A valid project-root junction is intentional, so resolve it.
case "${PWF_PLAN_ROOT}" in
\\\\*|//*|[A-Za-z]:[!\\/]*) return 1 ;;
/*|[A-Za-z]:[\\/]*) ;;
*) return 1 ;;
esac
target_root="$(cd "${PWF_PLAN_ROOT}" 2>/dev/null && pwd -P)" || return 1
fi
[ ! -L "${target_root}/task_plan.md" ] || return 1
[ -f "${target_root}/task_plan.md" ] || return 1
printf "%s\n" "${target_root}/task_plan.md"
return 0
fi
slug_is_valid "${target}" || return 1
# Reuse the binding, project-pin and containment rules. A flag replaces
# PLAN_ID for this call only; it does not change the shared pointer.
[ -f "${RESOLVER}" ] || return 1
target_dir="$(PLAN_ID="${target}" sh "${RESOLVER}" 2>/dev/null)" || return 1
[ -n "${target_dir}" ] || return 1
# Use the shell's physical path spelling: native Windows backslashes
# make sha256sum prefix its output with an escaped-filename marker.
target_dir="$(cd "${target_dir}" 2>/dev/null && pwd -P)" || return 1
[ ! -L "${target_dir}/task_plan.md" ] || return 1
[ -f "${target_dir}/task_plan.md" ] || return 1
printf "%s\n" "${target_dir}/task_plan.md"
return 0
fi
plan_dir=""
if [ -f "${RESOLVER}" ]; then
plan_dir="$(sh "${RESOLVER}" 2>/dev/null)"
@@ -80,7 +116,9 @@ resolve_plan_file() {
attestation_path_for() {
plan_file="$1"
plan_dir="$(dirname "${plan_file}")"
if [ "${plan_dir}" = "." ]; then
if [ "${target:-}" = "root" ]; then
printf "%s\n" "${plan_dir}/.plan-attestation"
elif [ "${plan_dir}" = "." ]; then
# Legacy mode: store at project root.
printf "%s\n" "./.plan-attestation"
else
@@ -101,14 +139,21 @@ compute_hash() {
}
mode="attest"
target=""
usage_error() {
printf "Usage: %s [--show|--clear|--target root|<plan-id>]\n" "$0" >&2
exit 2
}
case "${1:-}" in
--show) mode="show" ;;
--clear) mode="clear" ;;
"") mode="attest" ;;
*)
printf "Usage: %s [--show|--clear]\n" "$0" >&2
exit 2
--show) [ "$#" -eq 1 ] || usage_error; mode="show" ;;
--clear) [ "$#" -eq 1 ] || usage_error; mode="clear" ;;
--target)
[ "$#" -eq 2 ] || usage_error
[ -n "$2" ] || usage_error
target="$2"
;;
"") [ "$#" -eq 0 ] || usage_error ;;
*) usage_error ;;
esac
plan_file="$(resolve_plan_file)" || {
@@ -117,7 +162,9 @@ plan_file="$(resolve_plan_file)" || {
# a mistyped PLAN_ID attested a DIFFERENT plan at rc=0, and an operator
# who now sees a generic not-found is likely to go looking for the wrong
# problem. The selectors are bindings, so say which one refused.
if [ -n "${PLAN_ID:-}" ]; then
if [ -n "${target:-}" ]; then
printf "[plan-attest] --target %s did not resolve to a plan. An explicit target is a binding: nothing was attested and no other plan was substituted.\n" "${target}" >&2
elif [ -n "${PLAN_ID:-}" ]; then
printf "[plan-attest] PLAN_ID=%s names no plan directory under .planning. An explicit selector is a binding: nothing was attested and no other plan was substituted.\n" "${PLAN_ID}" >&2
elif [ -n "${PWF_PLAN_ROOT:-}" ]; then
printf "[plan-attest] PWF_PLAN_ROOT=%s did not resolve to a project root holding a plan. An explicit pin is a binding: nothing was attested and no other plan was substituted.\n" "${PWF_PLAN_ROOT}" >&2
@@ -156,6 +203,8 @@ case "${mode}" in
fi
;;
attest)
printf "Plan: %s\n" "${plan_file}"
printf "Attestation: %s\n" "${attestation_file}"
hash_val="$(compute_hash "${plan_file}")" || exit 1
# v2.40: protect the write with an advisory flock when available so
@@ -51,6 +51,8 @@ if ($PlanFile -ne "") {
}
if (-not (Test-Path $PlanFile)) {
# Automatic gate checks have nothing to report without a plan; the explicit report keeps its notice.
if ($Gate) { exit 0 }
Write-Host '[planning-with-files] No task_plan.md found -- no active planning session.'
exit 0
}
@@ -79,6 +79,9 @@ else
fi
if [ ! -f "$PLAN_FILE" ]; then
# Automatic gate checks have nothing to report without a plan (header:
# "plans stay silent in --gate mode"); the explicit report keeps its notice.
[ "$GATE" -eq 1 ] && exit 0
echo "[planning-with-files] No task_plan.md found — no active planning session."
exit 0
fi
@@ -18,6 +18,27 @@ param(
[switch]$Gated
)
# Windows PowerShell 5.1 can silently relocate a -File invocation when the
# inherited cwd contains wildcard characters such as [ or ]. Recover the
# physical cwd for a direct invocation before any project-relative paths are
# resolved.
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)) {
Set-Location -LiteralPath ([Environment]::CurrentDirectory)
}
break
}
}
}
$DATE = Get-Date -Format "yyyy-MM-dd"
# Resolve v3 opt-in mode. -Gated implies autonomous and is the stronger marker.
@@ -331,13 +352,39 @@ if ($UsePlanDir) {
# Activate the named plan only after all three planning files are ready.
# This matches init-session.sh and prevents a failed initialization from
# leaving .active_plan pointed at a partial plan directory.
$global:LASTEXITCODE = 0
try {
& $PlanSelector $PlanId *> $null
} catch {
$global:LASTEXITCODE = 1
$pointerSet = $false
for ($attempt = 1; $attempt -le 5; $attempt++) {
$global:LASTEXITCODE = 0
try {
$selectorResult = & $PlanSelector $PlanId 2>&1
} catch {
$selectorResult = $_
$global:LASTEXITCODE = 1
}
if ($LASTEXITCODE -eq 0) {
$pointerSet = $true
break
}
# Another writer can replace the pointer between the selector's
# Get-Item and final-path check. Retry only that transient result.
$transientPointerRace = $selectorResult -is [System.Management.Automation.ErrorRecord] -and
$selectorResult.Exception.Message -in @(
'Error: could not set the active plan pointer: the active plan pointer became unsafe during replacement',
'Error: the active plan pointer must be a regular file within the project.'
)
if ($attempt -eq 5 -or -not $transientPointerRace) {
break
}
Start-Sleep -Milliseconds 50
$global:LASTEXITCODE = 0
try {
& $PlanSelector -VerifyRoot *> $null
} catch {
$global:LASTEXITCODE = 1
}
if ($LASTEXITCODE -ne 0) { break }
}
if ($LASTEXITCODE -ne 0) {
if (-not $pointerSet) {
Write-Error "Error: could not safely update the active plan pointer at $(Join-Path $PlanningRoot '.active_plan')."
exit 1
}
@@ -128,9 +128,58 @@ function Test-SafeActiveFile {
$linked = ([string]$item.LinkType) -in @('SymbolicLink', 'Junction')
return -not $item.PSIsContainer -and
($AllowLink -or -not $linked) -and
($AllowLink -or -not $item.IsReadOnly) -and
(Test-WithinRoot $ActiveFile)
}
function Test-SafeOwnedBackupFile {
param([string]$Path)
$item = Get-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue
if (-not $item) { return $false }
$linked = ([string]$item.LinkType) -in @('SymbolicLink', 'Junction')
return -not $item.PSIsContainer -and -not $linked -and (Test-WithinRoot $Path)
}
function Resolve-OwnedPointerBackup {
param([string]$BackupFile, [switch]$ReplaceSucceeded)
# ReplaceFile can move the old destination before failing to move the new
# file. A caller-named backup makes that intermediate state attributable to
# this invocation, so recover or delete only this GUID path. Never glob for
# ~RF*.TMP: a concurrent selector may own those files.
$maxAttempts = 20
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
$backupItem = Get-Item -LiteralPath $BackupFile -Force -ErrorAction SilentlyContinue
if (-not $backupItem) { return $true }
# A failed inspection is transient under concurrent writers: retry.
if (-not (Test-SafeOwnedBackupFile $BackupFile)) { Start-Sleep -Milliseconds 25; continue }
$currentActive = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
# After our own successful replacement the backup holds a superseded
# value: only delete it, never move it back over a newer pointer.
if ($ReplaceSucceeded -or $currentActive) {
if (-not $ReplaceSucceeded -and -not (Test-SafeActiveFile)) { Start-Sleep -Milliseconds 25; continue }
try {
[IO.File]::Delete($BackupFile)
return $true
} catch {
if ($attempt -eq $maxAttempts) { return $false }
}
} else {
try {
[IO.File]::Move($BackupFile, $ActiveFile)
return $true
} catch {
# Another selector may have recreated the pointer between the
# absence check and Move. Reinspect before deciding whether the
# owned backup is now redundant.
if ($attempt -eq $maxAttempts) { return $false }
}
}
Start-Sleep -Milliseconds 25
}
return $false
}
function Get-CurrentActivePlan {
if (-not (Test-SafeActiveFile -AllowLink)) { return "" }
try {
@@ -305,9 +354,16 @@ if ($activeItem -and -not (Test-SafeActiveFile)) {
$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')
# file and caller-owned backup live beside the pointer, keeping replacement on
# the same filesystem. Naming the backup prevents ReplaceFile from inventing
# an unowned ~RF*.TMP path if its final rename fails.
$operationId = [guid]::NewGuid().ToString('N')
$tempFile = Join-Path $PlanRoot ('.active_plan.' + $operationId + '.tmp')
$backupFile = Join-Path $PlanRoot ('.active_plan.' + $operationId + '.replace-backup')
$createdTemp = $false
$failureMessage = ""
$backupResolved = $true
$replaced = $false
try {
$stream = [IO.File]::Open($tempFile, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
$createdTemp = $true
@@ -316,19 +372,48 @@ try {
$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)
$maxAttempts = 20
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
try {
$currentActive = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
if ($currentActive) {
if (-not (Test-SafeActiveFile)) {
throw [InvalidOperationException]::new("the active plan pointer became unsafe during replacement")
}
[IO.File]::Replace($tempFile, $ActiveFile, $backupFile)
} else {
[IO.File]::Move($tempFile, $ActiveFile)
}
$replaced = $true
break
} catch [IO.IOException] {
if (-not (Resolve-OwnedPointerBackup -BackupFile $backupFile)) {
throw [IO.IOException]::new("could not safely recover the active plan pointer after replacement failure")
}
if ($attempt -eq $maxAttempts) { throw }
Start-Sleep -Milliseconds 25
}
}
} catch {
Write-Error "Error: could not set the active plan pointer: $($_.Exception.Message)"
exit 1
$failureMessage = $_.Exception.Message
} finally {
if (-not (Resolve-OwnedPointerBackup -BackupFile $backupFile -ReplaceSucceeded:$replaced)) {
# The pointer was written; a leftover backup is clutter, not failure.
if ($replaced) { Write-Warning "could not remove the replacement backup $backupFile" }
else { $backupResolved = $false }
}
if ($createdTemp -and (Test-Path -LiteralPath $tempFile)) {
Remove-Item -LiteralPath $tempFile -Force -ErrorAction SilentlyContinue
}
}
if (-not $backupResolved) {
Write-Error "Error: could not safely recover the active plan pointer backup."
exit 1
}
if ($failureMessage) {
Write-Error "Error: could not set the active plan pointer: $failureMessage"
exit 1
}
Write-Output "Active plan set to: $PlanId"
Write-Output "Path: $PlanDir"
Write-Output ""
+2 -2
View File
@@ -1,9 +1,9 @@
{
"version": 1,
"hooks": {
"userPromptSubmit": [
"sessionStart": [
{
"command": ".cursor/hooks/user-prompt-submit.sh",
"command": ".cursor/hooks/session-start.sh",
"timeout": 5
}
],
+5 -5
View File
@@ -1,29 +1,29 @@
{
"version": 1,
"hooks": {
"userPromptSubmit": [
"sessionStart": [
{
"command": "powershell -ExecutionPolicy Bypass -File .cursor/hooks/user-prompt-submit.ps1",
"command": "powershell -NoProfile -ExecutionPolicy Bypass -File .cursor/hooks/session-start.ps1",
"timeout": 5
}
],
"preToolUse": [
{
"command": "powershell -ExecutionPolicy Bypass -File .cursor/hooks/pre-tool-use.ps1",
"command": "powershell -NoProfile -ExecutionPolicy Bypass -File .cursor/hooks/pre-tool-use.ps1",
"matcher": "Write|Edit|Shell|Read",
"timeout": 5
}
],
"postToolUse": [
{
"command": "powershell -ExecutionPolicy Bypass -File .cursor/hooks/post-tool-use.ps1",
"command": "powershell -NoProfile -ExecutionPolicy Bypass -File .cursor/hooks/post-tool-use.ps1",
"matcher": "Write|Edit",
"timeout": 5
}
],
"stop": [
{
"command": "powershell -ExecutionPolicy Bypass -File .cursor/hooks/stop.ps1",
"command": "powershell -NoProfile -ExecutionPolicy Bypass -File .cursor/hooks/stop.ps1",
"timeout": 10,
"loop_limit": 3
}
+23 -11
View File
@@ -1,24 +1,36 @@
# planning-with-files: Post-tool-use hook for Cursor (PowerShell)
# Reminds the agent to update task_plan.md after file modifications.
# Injects a reminder after file modifications using postToolUse's context field.
# Issue #195 opt-out. The disabled branch reproduces this hook's own
# no-plan-file behaviour, so the Cursor protocol shape never changes.
if ($env:PLANNING_DISABLED -eq '1') { exit 0 }
if ($env:PLANNING_DISABLED -eq '1') {
Write-Output '{}'
exit 0
}
# The OEM code page turns the em-dash into "-" and non-ASCII plan text into "?"
# on both Windows PowerShell 5.1 and pwsh; the plan reaches Cursor as UTF-8.
# ConstrainedLanguage may refuse the assignment, which only keeps the old bytes.
try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch { }
. (Join-Path $PSScriptRoot "resolve-plan-context.ps1")
$PlanContext = Resolve-CursorPlanContext
$PlanFile = if ($PlanContext.Directory) {
Join-Path $PlanContext.Directory "task_plan.md"
} else {
$null
}
try {
. (Join-Path $PSScriptRoot "resolve-plan-context.ps1")
$PlanContext = Resolve-CursorPlanContext
$PlanFile = if ($PlanContext.Directory) {
Join-Path $PlanContext.Directory "task_plan.md"
} else {
$null
}
if ($PlanFile -and (Test-Path -LiteralPath $PlanFile -PathType Leaf)) {
Write-Output "[planning-with-files] Update progress.md with what you just did. If a phase is now complete, update task_plan.md status."
if ($PlanFile -and (Test-Path -LiteralPath $PlanFile -PathType Leaf)) {
$response = @{
additional_context = "[planning-with-files] Update progress.md with what you just did. If a phase is now complete, update task_plan.md status."
}
Write-Output ($response | ConvertTo-Json -Compress)
} else {
Write-Output '{}'
}
} catch {
Write-Output '{}'
}
exit 0
Regular → Executable
+5 -3
View File
@@ -1,12 +1,14 @@
#!/bin/bash
# planning-with-files: Post-tool-use hook for Cursor
# Reminds the agent to update task_plan.md after file modifications.
# Injects a reminder after file modifications using postToolUse's context field.
# Issue #195 opt-out. The disabled branch reproduces this hook's own
# no-plan-file behaviour, so the Cursor protocol shape never changes.
[ "${PLANNING_DISABLED:-}" = "1" ] && exit 0
[ "${PLANNING_DISABLED:-}" = "1" ] && { echo '{}'; exit 0; }
if [ -f task_plan.md ]; then
echo "[planning-with-files] Update progress.md with what you just did. If a phase is now complete, update task_plan.md status."
echo '{"additional_context":"[planning-with-files] Update progress.md with what you just did. If a phase is now complete, update task_plan.md status."}'
else
echo '{}'
fi
exit 0
+4 -35
View File
@@ -1,37 +1,6 @@
# planning-with-files: Pre-tool-use hook for Cursor (PowerShell)
# Reads the first 30 lines of task_plan.md to keep goals in context.
# Returns {"decision": "allow"} — this hook never blocks tools.
# planning-with-files: Pre-tool-use hook for Cursor (PowerShell)
# Cursor preToolUse is a permission hook. Plan context is injected by sessionStart.
# Always allow tools; this hook never blocks them.
# Issue #195 opt-out. The disabled branch reproduces this hook's own
# no-plan-file behaviour, so the Cursor protocol shape never changes.
if ($env:PLANNING_DISABLED -eq '1') {
Write-Output '{"decision": "allow"}'
exit 0
}
# The OEM code page turns the em-dash into "-" and non-ASCII plan text into "?"
# on both Windows PowerShell 5.1 and pwsh; the plan reaches Cursor as UTF-8.
# ConstrainedLanguage may refuse the assignment, which only keeps the old bytes.
try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch { }
# The protocol response is printed from finally: a missing helper or a
# profile that sets $ErrorActionPreference = 'Stop' must not end the hook
# without it. This hook never blocks a tool.
try {
. (Join-Path $PSScriptRoot "resolve-plan-context.ps1")
$PlanContext = Resolve-CursorPlanContext
$PlanFile = if ($PlanContext.Directory) {
Join-Path $PlanContext.Directory "task_plan.md"
} else {
$null
}
if ($PlanFile -and (Test-Path -LiteralPath $PlanFile -PathType Leaf)) {
Get-Content -LiteralPath $PlanFile -TotalCount 30 | Write-Host
}
} catch {
# A planning error is never a reason to hold a tool.
} finally {
Write-Output '{"decision": "allow"}'
}
Write-Output '{"permission":"allow"}'
exit 0
Regular → Executable
+3 -14
View File
@@ -1,18 +1,7 @@
#!/bin/bash
# planning-with-files: Pre-tool-use hook for Cursor
# Reads the first 30 lines of task_plan.md to keep goals in context.
# Returns {"decision": "allow"} — this hook never blocks tools.
# Cursor preToolUse is a permission hook. Plan context is injected by sessionStart.
# Always allow tools; this hook never blocks them.
# Issue #195 opt-out. The disabled branch reproduces this hook's own
# no-plan-file behaviour, so the Cursor protocol shape never changes.
[ "${PLANNING_DISABLED:-}" = "1" ] && { echo '{"decision": "allow"}'; exit 0; }
PLAN_FILE="task_plan.md"
if [ -f "$PLAN_FILE" ]; then
# Log plan context to stderr (visible in Cursor's hook logs)
head -30 "$PLAN_FILE" >&2
fi
echo '{"decision": "allow"}'
echo '{"permission":"allow"}'
exit 0
+18
View File
@@ -0,0 +1,18 @@
# Inject the selected plan as initial session context using Cursor's sessionStart schema.
if ($env:PLANNING_DISABLED -eq '1') {
Write-Output '{}'
exit 0
}
try {
$context = & (Join-Path $PSScriptRoot 'user-prompt-submit.ps1') | Out-String
if ([string]::IsNullOrWhiteSpace($context)) {
Write-Output '{}'
exit 0
}
$response = @{ additional_context = $context.TrimEnd() }
Write-Output ($response | ConvertTo-Json -Compress)
} catch {
Write-Output '{}'
}
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env bash
# Inject the selected plan as initial session context using Cursor's sessionStart schema.
if [ "${PLANNING_DISABLED:-}" = "1" ]; then
echo '{}'
exit 0
fi
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
CONTEXT="$(sh "${SCRIPT_DIR}/user-prompt-submit.sh")"
if [ -z "${CONTEXT}" ]; then
echo '{}'
exit 0
fi
# JSON-escape the context with the first interpreter that actually runs:
# python3 can be a stub (the Windows Store alias), so python is tried next.
# -I keeps a json.py in the project directory from being imported.
RESPONSE=""
for PYTHON in "$(command -v python3 2>/dev/null)" "$(command -v python 2>/dev/null)"; do
[ -n "${PYTHON}" ] || continue
RESPONSE="$(printf '%s' "${CONTEXT}" | "$PYTHON" -I -X utf8 -c 'import json,sys; print(json.dumps({"additional_context": sys.stdin.buffer.read().decode("utf-8", "replace")}))' 2>/dev/null)" || RESPONSE=""
[ -n "${RESPONSE}" ] && break
done
# Keep the response valid when no Python runs; planning scripts require
# Python for their normal context-injection path as well.
if [ -n "${RESPONSE}" ]; then
printf '%s\n' "${RESPONSE}"
else
echo '{}'
fi
exit 0
+2 -1
View File
@@ -48,7 +48,8 @@ if ($TOTAL -eq 0) {
}
if ($COMPLETE -eq $TOTAL -and $TOTAL -gt 0) {
Write-Host "{`"followup_message`": `"[planning-with-files] ALL PHASES COMPLETE ($COMPLETE/$TOTAL). If the user has additional work, add new phases to task_plan.md before starting.`"}"
# All phases complete: stay silent. Cursor submits a followup_message as
# the next user message, so a finished plan must not produce one.
exit 0
} else {
Write-Host "{`"followup_message`": `"[planning-with-files] Task incomplete ($COMPLETE/$TOTAL phases done). Update progress.md, then read task_plan.md and continue working on the remaining phases.`"}"
Regular → Executable
+2 -2
View File
@@ -43,8 +43,8 @@ if [ "$TOTAL" -eq 0 ]; then
fi
if [ "$COMPLETE" -eq "$TOTAL" ] && [ "$TOTAL" -gt 0 ]; then
# All phases complete — provide re-entry guidance
echo "{\"followup_message\": \"[planning-with-files] ALL PHASES COMPLETE ($COMPLETE/$TOTAL). If the user has additional work, add new phases to task_plan.md before starting.\"}"
# All phases complete: stay silent. Cursor submits a followup_message as
# the next user message, so a finished plan must not produce one.
exit 0
else
# Phases incomplete — auto-continue via followup_message
+4 -4
View File
@@ -4,7 +4,7 @@
# Issue #195 opt-out. The disabled branch reproduces this hook's own
# no-plan-file behaviour, so the Cursor protocol shape never changes.
if ($env:PLANNING_DISABLED -eq '1') { exit 0 }
if ($env:PLANNING_DISABLED -eq '1') { return }
# The OEM code page turns the em-dash into "-" and non-ASCII plan text into "?"
# on both Windows PowerShell 5.1 and pwsh; the plan reaches Cursor as UTF-8.
@@ -33,7 +33,7 @@ if (-not $PlanContext.Directory) {
Write-Output "[planning-with-files] The selected plan could not be resolved safely ($($PlanContext.Detail)). Check PLAN_ID, PWF_PLAN_ROOT, and .active_plan; nothing injected."
}
}
exit 0
return
}
$planFile = Join-Path $PlanContext.Directory "task_plan.md"
@@ -69,7 +69,7 @@ if (Test-Path -LiteralPath $planFile -PathType Leaf) {
if ($nestedRoots.Count -gt 0) {
$nestedList = (@($nestedRoots | Select-Object -First 3)) -join ", "
Write-Output "[planning-with-files] Ambiguous plan: this cwd has an active plan and a nested project below it has its own ($nestedList). Nothing injected. Pin the thread with PWF_PLAN_ROOT=<absolute path> or PLAN_ID=<slug>."
exit 0
return
}
}
@@ -90,4 +90,4 @@ if (Test-Path -LiteralPath $planFile -PathType Leaf) {
Write-Output ""
Write-Output "[planning-with-files] Read findings.md for research context. Continue from the current phase."
}
exit 0
return
View File
+1 -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.20.5"
version: "3.21.0"
---
@@ -6,16 +6,19 @@
# attested hash, surfacing a "[PLAN TAMPERED]" warning instead.
#
# Resolution:
# 1. $PLAN_ID env var → ./.planning/$PLAN_ID/
# 2. ./.planning/.active_plan
# 3. Newest ./.planning/<dir>/ by mtime
# 4. Current directory when it is .planning/<valid-slug>/
# 5. Legacy ./task_plan.md at project root
# 1. --target root or <plan-id> → root or named plan under $PWF_PLAN_ROOT (default: cwd)
# 2. $PLAN_ID env var → ./.planning/$PLAN_ID/
# 3. ./.planning/.active_plan
# 4. Newest ./.planning/<dir>/ by mtime
# 5. Current directory when it is .planning/<valid-slug>/
# 6. Legacy ./task_plan.md at project root
#
# Usage:
# sh scripts/attest-plan.sh # attest the active plan
# sh scripts/attest-plan.sh --show # print the stored hash
# sh scripts/attest-plan.sh --clear # remove the attestation (re-open the plan)
# sh scripts/attest-plan.sh --target root # attest the legacy root plan
# sh scripts/attest-plan.sh --target <plan-id> # attest one named plan
set -u
@@ -42,6 +45,39 @@ resolve_from_slug_cwd() {
}
resolve_plan_file() {
if [ -n "${target:-}" ]; then
if [ "${target}" = "root" ]; then
target_root="."
if [ -n "${PWF_PLAN_ROOT:-}" ]; then
# Match the shared resolver's supported absolute local pins.
# A valid project-root junction is intentional, so resolve it.
case "${PWF_PLAN_ROOT}" in
\\\\*|//*|[A-Za-z]:[!\\/]*) return 1 ;;
/*|[A-Za-z]:[\\/]*) ;;
*) return 1 ;;
esac
target_root="$(cd "${PWF_PLAN_ROOT}" 2>/dev/null && pwd -P)" || return 1
fi
[ ! -L "${target_root}/task_plan.md" ] || return 1
[ -f "${target_root}/task_plan.md" ] || return 1
printf "%s\n" "${target_root}/task_plan.md"
return 0
fi
slug_is_valid "${target}" || return 1
# Reuse the binding, project-pin and containment rules. A flag replaces
# PLAN_ID for this call only; it does not change the shared pointer.
[ -f "${RESOLVER}" ] || return 1
target_dir="$(PLAN_ID="${target}" sh "${RESOLVER}" 2>/dev/null)" || return 1
[ -n "${target_dir}" ] || return 1
# Use the shell's physical path spelling: native Windows backslashes
# make sha256sum prefix its output with an escaped-filename marker.
target_dir="$(cd "${target_dir}" 2>/dev/null && pwd -P)" || return 1
[ ! -L "${target_dir}/task_plan.md" ] || return 1
[ -f "${target_dir}/task_plan.md" ] || return 1
printf "%s\n" "${target_dir}/task_plan.md"
return 0
fi
plan_dir=""
if [ -f "${RESOLVER}" ]; then
plan_dir="$(sh "${RESOLVER}" 2>/dev/null)"
@@ -80,7 +116,9 @@ resolve_plan_file() {
attestation_path_for() {
plan_file="$1"
plan_dir="$(dirname "${plan_file}")"
if [ "${plan_dir}" = "." ]; then
if [ "${target:-}" = "root" ]; then
printf "%s\n" "${plan_dir}/.plan-attestation"
elif [ "${plan_dir}" = "." ]; then
# Legacy mode: store at project root.
printf "%s\n" "./.plan-attestation"
else
@@ -101,14 +139,21 @@ compute_hash() {
}
mode="attest"
target=""
usage_error() {
printf "Usage: %s [--show|--clear|--target root|<plan-id>]\n" "$0" >&2
exit 2
}
case "${1:-}" in
--show) mode="show" ;;
--clear) mode="clear" ;;
"") mode="attest" ;;
*)
printf "Usage: %s [--show|--clear]\n" "$0" >&2
exit 2
--show) [ "$#" -eq 1 ] || usage_error; mode="show" ;;
--clear) [ "$#" -eq 1 ] || usage_error; mode="clear" ;;
--target)
[ "$#" -eq 2 ] || usage_error
[ -n "$2" ] || usage_error
target="$2"
;;
"") [ "$#" -eq 0 ] || usage_error ;;
*) usage_error ;;
esac
plan_file="$(resolve_plan_file)" || {
@@ -117,7 +162,9 @@ plan_file="$(resolve_plan_file)" || {
# a mistyped PLAN_ID attested a DIFFERENT plan at rc=0, and an operator
# who now sees a generic not-found is likely to go looking for the wrong
# problem. The selectors are bindings, so say which one refused.
if [ -n "${PLAN_ID:-}" ]; then
if [ -n "${target:-}" ]; then
printf "[plan-attest] --target %s did not resolve to a plan. An explicit target is a binding: nothing was attested and no other plan was substituted.\n" "${target}" >&2
elif [ -n "${PLAN_ID:-}" ]; then
printf "[plan-attest] PLAN_ID=%s names no plan directory under .planning. An explicit selector is a binding: nothing was attested and no other plan was substituted.\n" "${PLAN_ID}" >&2
elif [ -n "${PWF_PLAN_ROOT:-}" ]; then
printf "[plan-attest] PWF_PLAN_ROOT=%s did not resolve to a project root holding a plan. An explicit pin is a binding: nothing was attested and no other plan was substituted.\n" "${PWF_PLAN_ROOT}" >&2
@@ -156,6 +203,8 @@ case "${mode}" in
fi
;;
attest)
printf "Plan: %s\n" "${plan_file}"
printf "Attestation: %s\n" "${attestation_file}"
hash_val="$(compute_hash "${plan_file}")" || exit 1
# v2.40: protect the write with an advisory flock when available so
@@ -51,6 +51,8 @@ if ($PlanFile -ne "") {
}
if (-not (Test-Path $PlanFile)) {
# Automatic gate checks have nothing to report without a plan; the explicit report keeps its notice.
if ($Gate) { exit 0 }
Write-Host '[planning-with-files] No task_plan.md found -- no active planning session.'
exit 0
}
+3
View File
@@ -79,6 +79,9 @@ else
fi
if [ ! -f "$PLAN_FILE" ]; then
# Automatic gate checks have nothing to report without a plan (header:
# "plans stay silent in --gate mode"); the explicit report keeps its notice.
[ "$GATE" -eq 1 ] && exit 0
echo "[planning-with-files] No task_plan.md found — no active planning session."
exit 0
fi
@@ -18,6 +18,27 @@ param(
[switch]$Gated
)
# Windows PowerShell 5.1 can silently relocate a -File invocation when the
# inherited cwd contains wildcard characters such as [ or ]. Recover the
# physical cwd for a direct invocation before any project-relative paths are
# resolved.
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)) {
Set-Location -LiteralPath ([Environment]::CurrentDirectory)
}
break
}
}
}
$DATE = Get-Date -Format "yyyy-MM-dd"
# Resolve v3 opt-in mode. -Gated implies autonomous and is the stronger marker.
@@ -331,13 +352,39 @@ if ($UsePlanDir) {
# Activate the named plan only after all three planning files are ready.
# This matches init-session.sh and prevents a failed initialization from
# leaving .active_plan pointed at a partial plan directory.
$global:LASTEXITCODE = 0
try {
& $PlanSelector $PlanId *> $null
} catch {
$global:LASTEXITCODE = 1
$pointerSet = $false
for ($attempt = 1; $attempt -le 5; $attempt++) {
$global:LASTEXITCODE = 0
try {
$selectorResult = & $PlanSelector $PlanId 2>&1
} catch {
$selectorResult = $_
$global:LASTEXITCODE = 1
}
if ($LASTEXITCODE -eq 0) {
$pointerSet = $true
break
}
# Another writer can replace the pointer between the selector's
# Get-Item and final-path check. Retry only that transient result.
$transientPointerRace = $selectorResult -is [System.Management.Automation.ErrorRecord] -and
$selectorResult.Exception.Message -in @(
'Error: could not set the active plan pointer: the active plan pointer became unsafe during replacement',
'Error: the active plan pointer must be a regular file within the project.'
)
if ($attempt -eq 5 -or -not $transientPointerRace) {
break
}
Start-Sleep -Milliseconds 50
$global:LASTEXITCODE = 0
try {
& $PlanSelector -VerifyRoot *> $null
} catch {
$global:LASTEXITCODE = 1
}
if ($LASTEXITCODE -ne 0) { break }
}
if ($LASTEXITCODE -ne 0) {
if (-not $pointerSet) {
Write-Error "Error: could not safely update the active plan pointer at $(Join-Path $PlanningRoot '.active_plan')."
exit 1
}
@@ -128,9 +128,58 @@ function Test-SafeActiveFile {
$linked = ([string]$item.LinkType) -in @('SymbolicLink', 'Junction')
return -not $item.PSIsContainer -and
($AllowLink -or -not $linked) -and
($AllowLink -or -not $item.IsReadOnly) -and
(Test-WithinRoot $ActiveFile)
}
function Test-SafeOwnedBackupFile {
param([string]$Path)
$item = Get-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue
if (-not $item) { return $false }
$linked = ([string]$item.LinkType) -in @('SymbolicLink', 'Junction')
return -not $item.PSIsContainer -and -not $linked -and (Test-WithinRoot $Path)
}
function Resolve-OwnedPointerBackup {
param([string]$BackupFile, [switch]$ReplaceSucceeded)
# ReplaceFile can move the old destination before failing to move the new
# file. A caller-named backup makes that intermediate state attributable to
# this invocation, so recover or delete only this GUID path. Never glob for
# ~RF*.TMP: a concurrent selector may own those files.
$maxAttempts = 20
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
$backupItem = Get-Item -LiteralPath $BackupFile -Force -ErrorAction SilentlyContinue
if (-not $backupItem) { return $true }
# A failed inspection is transient under concurrent writers: retry.
if (-not (Test-SafeOwnedBackupFile $BackupFile)) { Start-Sleep -Milliseconds 25; continue }
$currentActive = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
# After our own successful replacement the backup holds a superseded
# value: only delete it, never move it back over a newer pointer.
if ($ReplaceSucceeded -or $currentActive) {
if (-not $ReplaceSucceeded -and -not (Test-SafeActiveFile)) { Start-Sleep -Milliseconds 25; continue }
try {
[IO.File]::Delete($BackupFile)
return $true
} catch {
if ($attempt -eq $maxAttempts) { return $false }
}
} else {
try {
[IO.File]::Move($BackupFile, $ActiveFile)
return $true
} catch {
# Another selector may have recreated the pointer between the
# absence check and Move. Reinspect before deciding whether the
# owned backup is now redundant.
if ($attempt -eq $maxAttempts) { return $false }
}
}
Start-Sleep -Milliseconds 25
}
return $false
}
function Get-CurrentActivePlan {
if (-not (Test-SafeActiveFile -AllowLink)) { return "" }
try {
@@ -305,9 +354,16 @@ if ($activeItem -and -not (Test-SafeActiveFile)) {
$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')
# file and caller-owned backup live beside the pointer, keeping replacement on
# the same filesystem. Naming the backup prevents ReplaceFile from inventing
# an unowned ~RF*.TMP path if its final rename fails.
$operationId = [guid]::NewGuid().ToString('N')
$tempFile = Join-Path $PlanRoot ('.active_plan.' + $operationId + '.tmp')
$backupFile = Join-Path $PlanRoot ('.active_plan.' + $operationId + '.replace-backup')
$createdTemp = $false
$failureMessage = ""
$backupResolved = $true
$replaced = $false
try {
$stream = [IO.File]::Open($tempFile, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
$createdTemp = $true
@@ -316,19 +372,48 @@ try {
$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)
$maxAttempts = 20
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
try {
$currentActive = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
if ($currentActive) {
if (-not (Test-SafeActiveFile)) {
throw [InvalidOperationException]::new("the active plan pointer became unsafe during replacement")
}
[IO.File]::Replace($tempFile, $ActiveFile, $backupFile)
} else {
[IO.File]::Move($tempFile, $ActiveFile)
}
$replaced = $true
break
} catch [IO.IOException] {
if (-not (Resolve-OwnedPointerBackup -BackupFile $backupFile)) {
throw [IO.IOException]::new("could not safely recover the active plan pointer after replacement failure")
}
if ($attempt -eq $maxAttempts) { throw }
Start-Sleep -Milliseconds 25
}
}
} catch {
Write-Error "Error: could not set the active plan pointer: $($_.Exception.Message)"
exit 1
$failureMessage = $_.Exception.Message
} finally {
if (-not (Resolve-OwnedPointerBackup -BackupFile $backupFile -ReplaceSucceeded:$replaced)) {
# The pointer was written; a leftover backup is clutter, not failure.
if ($replaced) { Write-Warning "could not remove the replacement backup $backupFile" }
else { $backupResolved = $false }
}
if ($createdTemp -and (Test-Path -LiteralPath $tempFile)) {
Remove-Item -LiteralPath $tempFile -Force -ErrorAction SilentlyContinue
}
}
if (-not $backupResolved) {
Write-Error "Error: could not safely recover the active plan pointer backup."
exit 1
}
if ($failureMessage) {
Write-Error "Error: could not set the active plan pointer: $failureMessage"
exit 1
}
Write-Output "Active plan set to: $PlanId"
Write-Output "Path: $PlanDir"
Write-Output ""
@@ -372,6 +372,41 @@ describe("init and status", () => {
expect(buildContext(root, dir)).toContain("[planning-with-files] plan: ")
})
it("updates a regular active plan pointer through a directory junction project root", () => {
const linkedRoot = `${root}-junction`
fs.symlinkSync(root, linkedRoot, "junction")
try {
const first = initPlan(linkedRoot, { name: "First" }, env)
expect(first.ok).toBe(true)
const second = initPlan(linkedRoot, { name: "Second" }, env)
expect(second.ok).toBe(true)
const pointer = path.join(root, ".planning", ".active_plan")
expect(fs.lstatSync(pointer).isFile()).toBe(true)
expect(fs.readFileSync(pointer, "utf8")).toBe(`${second.plan_id}\n`)
} finally {
fs.rmSync(linkedRoot, { recursive: true, force: true })
}
})
it("refuses a linked planning directory without changing its external pointer", () => {
const outside = `${root}-outside`
const planning = path.join(root, ".planning")
fs.mkdirSync(outside)
const pointer = path.join(outside, ".active_plan")
fs.writeFileSync(pointer, "external-plan\n")
fs.symlinkSync(outside, planning, "junction")
try {
const result = initPlan(root, { name: "Blocked" }, env)
expect(result.ok).toBe(false)
expect(result.error).toContain("unsafe active plan pointer")
expect(fs.readFileSync(pointer, "utf8")).toBe("external-plan\n")
expect(fs.readdirSync(outside)).toEqual([".active_plan"])
} finally {
fs.rmSync(planning, { recursive: true, force: true })
fs.rmSync(outside, { recursive: true, force: true })
}
})
it("uses a real skill template when one is discoverable, and rejects unknown modes", () => {
const skill = path.join(root, ".agents", "skills", "planning-with-files", "templates")
fs.mkdirSync(skill, { recursive: true })
@@ -86,6 +86,40 @@ function writeText(target: string, text: string): void {
fs.writeFileSync(target, text, { encoding: "utf8" })
}
function activePlanPointerError(planningRoot: string): string | null {
const pointer = path.join(planningRoot, ".active_plan")
const realRoot = realpathOrNull(planningRoot)
if (!isRealDir(planningRoot) || !realRoot) return `refusing to replace unsafe active plan pointer: ${pointer}`
if (!fs.existsSync(pointer)) return null
const st = lstatSafe(pointer)
const real = realpathOrNull(pointer)
return !st || !st.isFile() || st.isSymbolicLink() || st.nlink > 1 || !real || !isInside(real, realRoot)
? `refusing to replace unsafe active plan pointer: ${pointer}`
: null
}
function writeActivePlan(planningRoot: string, planId: string): string | null {
const pointer = path.join(planningRoot, ".active_plan")
const unsafe = activePlanPointerError(planningRoot)
if (unsafe) return unsafe
const tmp = path.join(planningRoot, `.active_plan.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`)
let fd: number | null = null
try {
fd = fs.openSync(tmp, "wx")
fs.writeFileSync(fd, `${planId}\n`, { encoding: "utf8" })
fs.closeSync(fd)
fd = null
fs.renameSync(tmp, pointer)
return null
} catch (err) {
return `could not safely update active plan pointer: ${err instanceof Error ? err.message : String(err)}`
} finally {
if (fd !== null) fs.closeSync(fd)
try { fs.unlinkSync(tmp) } catch { /* already renamed or absent */ }
}
}
function realpathOrNull(target: string): string | null {
try {
return fs.realpathSync(target)
@@ -736,8 +770,11 @@ export function initPlan(
fs.mkdirSync(planningRoot, { recursive: true })
planId = `${new Date().toISOString().slice(0, 10)}-${slug}`
planDir = path.join(planningRoot, planId)
const pointerError = activePlanPointerError(planningRoot)
if (pointerError) return { ok: false, error: pointerError, project_dir: root }
fs.mkdirSync(planDir, { recursive: true })
writeText(path.join(planningRoot, ".active_plan"), `${planId}\n`)
const writeError = writeActivePlan(planningRoot, planId)
if (writeError) return { ok: false, error: writeError, project_dir: root }
}
const created = copyTemplates(planDir, templatesDir, template)
const result: InitResult = {
+1 -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.20.5"
version: "3.21.0"
---
# Planning with Files
@@ -6,16 +6,19 @@
# attested hash, surfacing a "[PLAN TAMPERED]" warning instead.
#
# Resolution:
# 1. $PLAN_ID env var → ./.planning/$PLAN_ID/
# 2. ./.planning/.active_plan
# 3. Newest ./.planning/<dir>/ by mtime
# 4. Current directory when it is .planning/<valid-slug>/
# 5. Legacy ./task_plan.md at project root
# 1. --target root or <plan-id> → root or named plan under $PWF_PLAN_ROOT (default: cwd)
# 2. $PLAN_ID env var → ./.planning/$PLAN_ID/
# 3. ./.planning/.active_plan
# 4. Newest ./.planning/<dir>/ by mtime
# 5. Current directory when it is .planning/<valid-slug>/
# 6. Legacy ./task_plan.md at project root
#
# Usage:
# sh scripts/attest-plan.sh # attest the active plan
# sh scripts/attest-plan.sh --show # print the stored hash
# sh scripts/attest-plan.sh --clear # remove the attestation (re-open the plan)
# sh scripts/attest-plan.sh --target root # attest the legacy root plan
# sh scripts/attest-plan.sh --target <plan-id> # attest one named plan
set -u
@@ -42,6 +45,39 @@ resolve_from_slug_cwd() {
}
resolve_plan_file() {
if [ -n "${target:-}" ]; then
if [ "${target}" = "root" ]; then
target_root="."
if [ -n "${PWF_PLAN_ROOT:-}" ]; then
# Match the shared resolver's supported absolute local pins.
# A valid project-root junction is intentional, so resolve it.
case "${PWF_PLAN_ROOT}" in
\\\\*|//*|[A-Za-z]:[!\\/]*) return 1 ;;
/*|[A-Za-z]:[\\/]*) ;;
*) return 1 ;;
esac
target_root="$(cd "${PWF_PLAN_ROOT}" 2>/dev/null && pwd -P)" || return 1
fi
[ ! -L "${target_root}/task_plan.md" ] || return 1
[ -f "${target_root}/task_plan.md" ] || return 1
printf "%s\n" "${target_root}/task_plan.md"
return 0
fi
slug_is_valid "${target}" || return 1
# Reuse the binding, project-pin and containment rules. A flag replaces
# PLAN_ID for this call only; it does not change the shared pointer.
[ -f "${RESOLVER}" ] || return 1
target_dir="$(PLAN_ID="${target}" sh "${RESOLVER}" 2>/dev/null)" || return 1
[ -n "${target_dir}" ] || return 1
# Use the shell's physical path spelling: native Windows backslashes
# make sha256sum prefix its output with an escaped-filename marker.
target_dir="$(cd "${target_dir}" 2>/dev/null && pwd -P)" || return 1
[ ! -L "${target_dir}/task_plan.md" ] || return 1
[ -f "${target_dir}/task_plan.md" ] || return 1
printf "%s\n" "${target_dir}/task_plan.md"
return 0
fi
plan_dir=""
if [ -f "${RESOLVER}" ]; then
plan_dir="$(sh "${RESOLVER}" 2>/dev/null)"
@@ -80,7 +116,9 @@ resolve_plan_file() {
attestation_path_for() {
plan_file="$1"
plan_dir="$(dirname "${plan_file}")"
if [ "${plan_dir}" = "." ]; then
if [ "${target:-}" = "root" ]; then
printf "%s\n" "${plan_dir}/.plan-attestation"
elif [ "${plan_dir}" = "." ]; then
# Legacy mode: store at project root.
printf "%s\n" "./.plan-attestation"
else
@@ -101,14 +139,21 @@ compute_hash() {
}
mode="attest"
target=""
usage_error() {
printf "Usage: %s [--show|--clear|--target root|<plan-id>]\n" "$0" >&2
exit 2
}
case "${1:-}" in
--show) mode="show" ;;
--clear) mode="clear" ;;
"") mode="attest" ;;
*)
printf "Usage: %s [--show|--clear]\n" "$0" >&2
exit 2
--show) [ "$#" -eq 1 ] || usage_error; mode="show" ;;
--clear) [ "$#" -eq 1 ] || usage_error; mode="clear" ;;
--target)
[ "$#" -eq 2 ] || usage_error
[ -n "$2" ] || usage_error
target="$2"
;;
"") [ "$#" -eq 0 ] || usage_error ;;
*) usage_error ;;
esac
plan_file="$(resolve_plan_file)" || {
@@ -117,7 +162,9 @@ plan_file="$(resolve_plan_file)" || {
# a mistyped PLAN_ID attested a DIFFERENT plan at rc=0, and an operator
# who now sees a generic not-found is likely to go looking for the wrong
# problem. The selectors are bindings, so say which one refused.
if [ -n "${PLAN_ID:-}" ]; then
if [ -n "${target:-}" ]; then
printf "[plan-attest] --target %s did not resolve to a plan. An explicit target is a binding: nothing was attested and no other plan was substituted.\n" "${target}" >&2
elif [ -n "${PLAN_ID:-}" ]; then
printf "[plan-attest] PLAN_ID=%s names no plan directory under .planning. An explicit selector is a binding: nothing was attested and no other plan was substituted.\n" "${PLAN_ID}" >&2
elif [ -n "${PWF_PLAN_ROOT:-}" ]; then
printf "[plan-attest] PWF_PLAN_ROOT=%s did not resolve to a project root holding a plan. An explicit pin is a binding: nothing was attested and no other plan was substituted.\n" "${PWF_PLAN_ROOT}" >&2
@@ -156,6 +203,8 @@ case "${mode}" in
fi
;;
attest)
printf "Plan: %s\n" "${plan_file}"
printf "Attestation: %s\n" "${attestation_file}"
hash_val="$(compute_hash "${plan_file}")" || exit 1
# v2.40: protect the write with an advisory flock when available so
@@ -51,6 +51,8 @@ if ($PlanFile -ne "") {
}
if (-not (Test-Path $PlanFile)) {
# Automatic gate checks have nothing to report without a plan; the explicit report keeps its notice.
if ($Gate) { exit 0 }
Write-Host '[planning-with-files] No task_plan.md found -- no active planning session.'
exit 0
}
@@ -79,6 +79,9 @@ else
fi
if [ ! -f "$PLAN_FILE" ]; then
# Automatic gate checks have nothing to report without a plan (header:
# "plans stay silent in --gate mode"); the explicit report keeps its notice.
[ "$GATE" -eq 1 ] && exit 0
echo "[planning-with-files] No task_plan.md found — no active planning session."
exit 0
fi
@@ -18,6 +18,27 @@ param(
[switch]$Gated
)
# Windows PowerShell 5.1 can silently relocate a -File invocation when the
# inherited cwd contains wildcard characters such as [ or ]. Recover the
# physical cwd for a direct invocation before any project-relative paths are
# resolved.
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)) {
Set-Location -LiteralPath ([Environment]::CurrentDirectory)
}
break
}
}
}
$DATE = Get-Date -Format "yyyy-MM-dd"
# Resolve v3 opt-in mode. -Gated implies autonomous and is the stronger marker.
@@ -331,13 +352,39 @@ if ($UsePlanDir) {
# Activate the named plan only after all three planning files are ready.
# This matches init-session.sh and prevents a failed initialization from
# leaving .active_plan pointed at a partial plan directory.
$global:LASTEXITCODE = 0
try {
& $PlanSelector $PlanId *> $null
} catch {
$global:LASTEXITCODE = 1
$pointerSet = $false
for ($attempt = 1; $attempt -le 5; $attempt++) {
$global:LASTEXITCODE = 0
try {
$selectorResult = & $PlanSelector $PlanId 2>&1
} catch {
$selectorResult = $_
$global:LASTEXITCODE = 1
}
if ($LASTEXITCODE -eq 0) {
$pointerSet = $true
break
}
# Another writer can replace the pointer between the selector's
# Get-Item and final-path check. Retry only that transient result.
$transientPointerRace = $selectorResult -is [System.Management.Automation.ErrorRecord] -and
$selectorResult.Exception.Message -in @(
'Error: could not set the active plan pointer: the active plan pointer became unsafe during replacement',
'Error: the active plan pointer must be a regular file within the project.'
)
if ($attempt -eq 5 -or -not $transientPointerRace) {
break
}
Start-Sleep -Milliseconds 50
$global:LASTEXITCODE = 0
try {
& $PlanSelector -VerifyRoot *> $null
} catch {
$global:LASTEXITCODE = 1
}
if ($LASTEXITCODE -ne 0) { break }
}
if ($LASTEXITCODE -ne 0) {
if (-not $pointerSet) {
Write-Error "Error: could not safely update the active plan pointer at $(Join-Path $PlanningRoot '.active_plan')."
exit 1
}
@@ -128,9 +128,58 @@ function Test-SafeActiveFile {
$linked = ([string]$item.LinkType) -in @('SymbolicLink', 'Junction')
return -not $item.PSIsContainer -and
($AllowLink -or -not $linked) -and
($AllowLink -or -not $item.IsReadOnly) -and
(Test-WithinRoot $ActiveFile)
}
function Test-SafeOwnedBackupFile {
param([string]$Path)
$item = Get-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue
if (-not $item) { return $false }
$linked = ([string]$item.LinkType) -in @('SymbolicLink', 'Junction')
return -not $item.PSIsContainer -and -not $linked -and (Test-WithinRoot $Path)
}
function Resolve-OwnedPointerBackup {
param([string]$BackupFile, [switch]$ReplaceSucceeded)
# ReplaceFile can move the old destination before failing to move the new
# file. A caller-named backup makes that intermediate state attributable to
# this invocation, so recover or delete only this GUID path. Never glob for
# ~RF*.TMP: a concurrent selector may own those files.
$maxAttempts = 20
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
$backupItem = Get-Item -LiteralPath $BackupFile -Force -ErrorAction SilentlyContinue
if (-not $backupItem) { return $true }
# A failed inspection is transient under concurrent writers: retry.
if (-not (Test-SafeOwnedBackupFile $BackupFile)) { Start-Sleep -Milliseconds 25; continue }
$currentActive = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
# After our own successful replacement the backup holds a superseded
# value: only delete it, never move it back over a newer pointer.
if ($ReplaceSucceeded -or $currentActive) {
if (-not $ReplaceSucceeded -and -not (Test-SafeActiveFile)) { Start-Sleep -Milliseconds 25; continue }
try {
[IO.File]::Delete($BackupFile)
return $true
} catch {
if ($attempt -eq $maxAttempts) { return $false }
}
} else {
try {
[IO.File]::Move($BackupFile, $ActiveFile)
return $true
} catch {
# Another selector may have recreated the pointer between the
# absence check and Move. Reinspect before deciding whether the
# owned backup is now redundant.
if ($attempt -eq $maxAttempts) { return $false }
}
}
Start-Sleep -Milliseconds 25
}
return $false
}
function Get-CurrentActivePlan {
if (-not (Test-SafeActiveFile -AllowLink)) { return "" }
try {
@@ -305,9 +354,16 @@ if ($activeItem -and -not (Test-SafeActiveFile)) {
$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')
# file and caller-owned backup live beside the pointer, keeping replacement on
# the same filesystem. Naming the backup prevents ReplaceFile from inventing
# an unowned ~RF*.TMP path if its final rename fails.
$operationId = [guid]::NewGuid().ToString('N')
$tempFile = Join-Path $PlanRoot ('.active_plan.' + $operationId + '.tmp')
$backupFile = Join-Path $PlanRoot ('.active_plan.' + $operationId + '.replace-backup')
$createdTemp = $false
$failureMessage = ""
$backupResolved = $true
$replaced = $false
try {
$stream = [IO.File]::Open($tempFile, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
$createdTemp = $true
@@ -316,19 +372,48 @@ try {
$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)
$maxAttempts = 20
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
try {
$currentActive = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
if ($currentActive) {
if (-not (Test-SafeActiveFile)) {
throw [InvalidOperationException]::new("the active plan pointer became unsafe during replacement")
}
[IO.File]::Replace($tempFile, $ActiveFile, $backupFile)
} else {
[IO.File]::Move($tempFile, $ActiveFile)
}
$replaced = $true
break
} catch [IO.IOException] {
if (-not (Resolve-OwnedPointerBackup -BackupFile $backupFile)) {
throw [IO.IOException]::new("could not safely recover the active plan pointer after replacement failure")
}
if ($attempt -eq $maxAttempts) { throw }
Start-Sleep -Milliseconds 25
}
}
} catch {
Write-Error "Error: could not set the active plan pointer: $($_.Exception.Message)"
exit 1
$failureMessage = $_.Exception.Message
} finally {
if (-not (Resolve-OwnedPointerBackup -BackupFile $backupFile -ReplaceSucceeded:$replaced)) {
# The pointer was written; a leftover backup is clutter, not failure.
if ($replaced) { Write-Warning "could not remove the replacement backup $backupFile" }
else { $backupResolved = $false }
}
if ($createdTemp -and (Test-Path -LiteralPath $tempFile)) {
Remove-Item -LiteralPath $tempFile -Force -ErrorAction SilentlyContinue
}
}
if (-not $backupResolved) {
Write-Error "Error: could not safely recover the active plan pointer backup."
exit 1
}
if ($failureMessage) {
Write-Error "Error: could not set the active plan pointer: $failureMessage"
exit 1
}
Write-Output "Active plan set to: $PlanId"
Write-Output "Path: $PlanDir"
Write-Output ""
Regular → Executable
+1 -1
View File
@@ -12,5 +12,5 @@ if [ ! -f "$PLAN_FILE" ]; then
exit 0
fi
echo '{"additionalContext":"[planning-with-files] Update progress.md with what you just did. If a phase is now complete, update task_plan.md status."}'
echo '{"hookSpecificOutput":{"additionalContext":"[planning-with-files] Update progress.md with what you just did. If a phase is now complete, update task_plan.md status."}}'
exit 0
+3 -3
View File
@@ -1,6 +1,6 @@
#!/bin/bash
# planning-with-files: BeforeTool hook for Gemini CLI
# Reads the first 30 lines of task_plan.md before tool use.
# planning-with-files: BeforeAgent hook for Gemini CLI
# Injects task_plan.md context into the user prompt once per turn.
# Receives JSON on stdin, must output ONLY JSON to stdout.
INPUT=$(cat)
@@ -23,7 +23,7 @@ PYTHON=$(command -v python3 || command -v python)
if [ -n "$PYTHON" ]; then
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}"
printf '%s\n' "{\"hookSpecificOutput\":{\"additionalContext\":$ESCAPED}}"
exit 0
fi
fi
-27
View File
@@ -1,27 +0,0 @@
#!/bin/bash
# planning-with-files: BeforeModel hook for Gemini CLI
# Injects plan awareness before every model call.
# This is UNIQUE to Gemini CLI — no other IDE has a BeforeModel event.
# Reads stdin JSON, outputs JSON to stdout.
INPUT=$(cat)
PLAN_FILE="task_plan.md"
if [ ! -f "$PLAN_FILE" ]; then
echo '{}'
exit 0
fi
# Only inject a lightweight reminder (not the full plan — that's BeforeTool's job)
CURRENT_PHASE=$(grep -m1 "^## Current Phase" "$PLAN_FILE" 2>/dev/null || grep -m1 "in_progress" "$PLAN_FILE" 2>/dev/null || echo "")
if [ -n "$CURRENT_PHASE" ]; then
PYTHON=$(command -v python3 || command -v python)
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 '{}'
fi
exit 0
Regular → Executable
View File
Regular → Executable
View File
+4 -18
View File
@@ -13,16 +13,15 @@
]
}
],
"BeforeTool": [
"BeforeAgent": [
{
"matcher": "write_file|edit_file|replace|create_file|patch|read_file|shell",
"hooks": [
{
"name": "planning-before-tool",
"name": "planning-before-agent",
"type": "command",
"command": "$GEMINI_PROJECT_DIR/.gemini/hooks/before-tool.sh",
"command": "$GEMINI_PROJECT_DIR/.gemini/hooks/before-agent.sh",
"timeout": 5000,
"description": "Read plan context before tool use to keep goals in attention"
"description": "Inject task_plan.md context into the user turn"
}
]
}
@@ -41,19 +40,6 @@
]
}
],
"BeforeModel": [
{
"hooks": [
{
"name": "planning-before-model",
"type": "command",
"command": "$GEMINI_PROJECT_DIR/.gemini/hooks/before-model.sh",
"timeout": 5000,
"description": "Inject plan awareness before every model call"
}
]
}
],
"SessionEnd": [
{
"hooks": [
+1 -1
View File
@@ -3,7 +3,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; Gemini 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. The session-end hook reports status only; it does not request continuation or run commands declared in Markdown. The skill has no network upload path. Use for research or work needing 5+ tool calls."
metadata:
version: "2.43.0"
hooks: "Configured in .gemini/settings.json (SessionStart, BeforeTool, AfterTool, BeforeModel)"
hooks: "Configured in .gemini/settings.json (SessionStart, BeforeAgent, AfterTool, SessionEnd)"
---
# Planning with Files
@@ -6,16 +6,19 @@
# attested hash, surfacing a "[PLAN TAMPERED]" warning instead.
#
# Resolution:
# 1. $PLAN_ID env var → ./.planning/$PLAN_ID/
# 2. ./.planning/.active_plan
# 3. Newest ./.planning/<dir>/ by mtime
# 4. Current directory when it is .planning/<valid-slug>/
# 5. Legacy ./task_plan.md at project root
# 1. --target root or <plan-id> → root or named plan under $PWF_PLAN_ROOT (default: cwd)
# 2. $PLAN_ID env var → ./.planning/$PLAN_ID/
# 3. ./.planning/.active_plan
# 4. Newest ./.planning/<dir>/ by mtime
# 5. Current directory when it is .planning/<valid-slug>/
# 6. Legacy ./task_plan.md at project root
#
# Usage:
# sh scripts/attest-plan.sh # attest the active plan
# sh scripts/attest-plan.sh --show # print the stored hash
# sh scripts/attest-plan.sh --clear # remove the attestation (re-open the plan)
# sh scripts/attest-plan.sh --target root # attest the legacy root plan
# sh scripts/attest-plan.sh --target <plan-id> # attest one named plan
set -u
@@ -42,6 +45,39 @@ resolve_from_slug_cwd() {
}
resolve_plan_file() {
if [ -n "${target:-}" ]; then
if [ "${target}" = "root" ]; then
target_root="."
if [ -n "${PWF_PLAN_ROOT:-}" ]; then
# Match the shared resolver's supported absolute local pins.
# A valid project-root junction is intentional, so resolve it.
case "${PWF_PLAN_ROOT}" in
\\\\*|//*|[A-Za-z]:[!\\/]*) return 1 ;;
/*|[A-Za-z]:[\\/]*) ;;
*) return 1 ;;
esac
target_root="$(cd "${PWF_PLAN_ROOT}" 2>/dev/null && pwd -P)" || return 1
fi
[ ! -L "${target_root}/task_plan.md" ] || return 1
[ -f "${target_root}/task_plan.md" ] || return 1
printf "%s\n" "${target_root}/task_plan.md"
return 0
fi
slug_is_valid "${target}" || return 1
# Reuse the binding, project-pin and containment rules. A flag replaces
# PLAN_ID for this call only; it does not change the shared pointer.
[ -f "${RESOLVER}" ] || return 1
target_dir="$(PLAN_ID="${target}" sh "${RESOLVER}" 2>/dev/null)" || return 1
[ -n "${target_dir}" ] || return 1
# Use the shell's physical path spelling: native Windows backslashes
# make sha256sum prefix its output with an escaped-filename marker.
target_dir="$(cd "${target_dir}" 2>/dev/null && pwd -P)" || return 1
[ ! -L "${target_dir}/task_plan.md" ] || return 1
[ -f "${target_dir}/task_plan.md" ] || return 1
printf "%s\n" "${target_dir}/task_plan.md"
return 0
fi
plan_dir=""
if [ -f "${RESOLVER}" ]; then
plan_dir="$(sh "${RESOLVER}" 2>/dev/null)"
@@ -80,7 +116,9 @@ resolve_plan_file() {
attestation_path_for() {
plan_file="$1"
plan_dir="$(dirname "${plan_file}")"
if [ "${plan_dir}" = "." ]; then
if [ "${target:-}" = "root" ]; then
printf "%s\n" "${plan_dir}/.plan-attestation"
elif [ "${plan_dir}" = "." ]; then
# Legacy mode: store at project root.
printf "%s\n" "./.plan-attestation"
else
@@ -101,14 +139,21 @@ compute_hash() {
}
mode="attest"
target=""
usage_error() {
printf "Usage: %s [--show|--clear|--target root|<plan-id>]\n" "$0" >&2
exit 2
}
case "${1:-}" in
--show) mode="show" ;;
--clear) mode="clear" ;;
"") mode="attest" ;;
*)
printf "Usage: %s [--show|--clear]\n" "$0" >&2
exit 2
--show) [ "$#" -eq 1 ] || usage_error; mode="show" ;;
--clear) [ "$#" -eq 1 ] || usage_error; mode="clear" ;;
--target)
[ "$#" -eq 2 ] || usage_error
[ -n "$2" ] || usage_error
target="$2"
;;
"") [ "$#" -eq 0 ] || usage_error ;;
*) usage_error ;;
esac
plan_file="$(resolve_plan_file)" || {
@@ -117,7 +162,9 @@ plan_file="$(resolve_plan_file)" || {
# a mistyped PLAN_ID attested a DIFFERENT plan at rc=0, and an operator
# who now sees a generic not-found is likely to go looking for the wrong
# problem. The selectors are bindings, so say which one refused.
if [ -n "${PLAN_ID:-}" ]; then
if [ -n "${target:-}" ]; then
printf "[plan-attest] --target %s did not resolve to a plan. An explicit target is a binding: nothing was attested and no other plan was substituted.\n" "${target}" >&2
elif [ -n "${PLAN_ID:-}" ]; then
printf "[plan-attest] PLAN_ID=%s names no plan directory under .planning. An explicit selector is a binding: nothing was attested and no other plan was substituted.\n" "${PLAN_ID}" >&2
elif [ -n "${PWF_PLAN_ROOT:-}" ]; then
printf "[plan-attest] PWF_PLAN_ROOT=%s did not resolve to a project root holding a plan. An explicit pin is a binding: nothing was attested and no other plan was substituted.\n" "${PWF_PLAN_ROOT}" >&2
@@ -156,6 +203,8 @@ case "${mode}" in
fi
;;
attest)
printf "Plan: %s\n" "${plan_file}"
printf "Attestation: %s\n" "${attestation_file}"
hash_val="$(compute_hash "${plan_file}")" || exit 1
# v2.40: protect the write with an advisory flock when available so
@@ -51,6 +51,8 @@ if ($PlanFile -ne "") {
}
if (-not (Test-Path $PlanFile)) {
# Automatic gate checks have nothing to report without a plan; the explicit report keeps its notice.
if ($Gate) { exit 0 }
Write-Host '[planning-with-files] No task_plan.md found -- no active planning session.'
exit 0
}
@@ -79,6 +79,9 @@ else
fi
if [ ! -f "$PLAN_FILE" ]; then
# Automatic gate checks have nothing to report without a plan (header:
# "plans stay silent in --gate mode"); the explicit report keeps its notice.
[ "$GATE" -eq 1 ] && exit 0
echo "[planning-with-files] No task_plan.md found — no active planning session."
exit 0
fi
@@ -18,6 +18,27 @@ param(
[switch]$Gated
)
# Windows PowerShell 5.1 can silently relocate a -File invocation when the
# inherited cwd contains wildcard characters such as [ or ]. Recover the
# physical cwd for a direct invocation before any project-relative paths are
# resolved.
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)) {
Set-Location -LiteralPath ([Environment]::CurrentDirectory)
}
break
}
}
}
$DATE = Get-Date -Format "yyyy-MM-dd"
# Resolve v3 opt-in mode. -Gated implies autonomous and is the stronger marker.
@@ -331,13 +352,39 @@ if ($UsePlanDir) {
# Activate the named plan only after all three planning files are ready.
# This matches init-session.sh and prevents a failed initialization from
# leaving .active_plan pointed at a partial plan directory.
$global:LASTEXITCODE = 0
try {
& $PlanSelector $PlanId *> $null
} catch {
$global:LASTEXITCODE = 1
$pointerSet = $false
for ($attempt = 1; $attempt -le 5; $attempt++) {
$global:LASTEXITCODE = 0
try {
$selectorResult = & $PlanSelector $PlanId 2>&1
} catch {
$selectorResult = $_
$global:LASTEXITCODE = 1
}
if ($LASTEXITCODE -eq 0) {
$pointerSet = $true
break
}
# Another writer can replace the pointer between the selector's
# Get-Item and final-path check. Retry only that transient result.
$transientPointerRace = $selectorResult -is [System.Management.Automation.ErrorRecord] -and
$selectorResult.Exception.Message -in @(
'Error: could not set the active plan pointer: the active plan pointer became unsafe during replacement',
'Error: the active plan pointer must be a regular file within the project.'
)
if ($attempt -eq 5 -or -not $transientPointerRace) {
break
}
Start-Sleep -Milliseconds 50
$global:LASTEXITCODE = 0
try {
& $PlanSelector -VerifyRoot *> $null
} catch {
$global:LASTEXITCODE = 1
}
if ($LASTEXITCODE -ne 0) { break }
}
if ($LASTEXITCODE -ne 0) {
if (-not $pointerSet) {
Write-Error "Error: could not safely update the active plan pointer at $(Join-Path $PlanningRoot '.active_plan')."
exit 1
}
@@ -128,9 +128,58 @@ function Test-SafeActiveFile {
$linked = ([string]$item.LinkType) -in @('SymbolicLink', 'Junction')
return -not $item.PSIsContainer -and
($AllowLink -or -not $linked) -and
($AllowLink -or -not $item.IsReadOnly) -and
(Test-WithinRoot $ActiveFile)
}
function Test-SafeOwnedBackupFile {
param([string]$Path)
$item = Get-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue
if (-not $item) { return $false }
$linked = ([string]$item.LinkType) -in @('SymbolicLink', 'Junction')
return -not $item.PSIsContainer -and -not $linked -and (Test-WithinRoot $Path)
}
function Resolve-OwnedPointerBackup {
param([string]$BackupFile, [switch]$ReplaceSucceeded)
# ReplaceFile can move the old destination before failing to move the new
# file. A caller-named backup makes that intermediate state attributable to
# this invocation, so recover or delete only this GUID path. Never glob for
# ~RF*.TMP: a concurrent selector may own those files.
$maxAttempts = 20
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
$backupItem = Get-Item -LiteralPath $BackupFile -Force -ErrorAction SilentlyContinue
if (-not $backupItem) { return $true }
# A failed inspection is transient under concurrent writers: retry.
if (-not (Test-SafeOwnedBackupFile $BackupFile)) { Start-Sleep -Milliseconds 25; continue }
$currentActive = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
# After our own successful replacement the backup holds a superseded
# value: only delete it, never move it back over a newer pointer.
if ($ReplaceSucceeded -or $currentActive) {
if (-not $ReplaceSucceeded -and -not (Test-SafeActiveFile)) { Start-Sleep -Milliseconds 25; continue }
try {
[IO.File]::Delete($BackupFile)
return $true
} catch {
if ($attempt -eq $maxAttempts) { return $false }
}
} else {
try {
[IO.File]::Move($BackupFile, $ActiveFile)
return $true
} catch {
# Another selector may have recreated the pointer between the
# absence check and Move. Reinspect before deciding whether the
# owned backup is now redundant.
if ($attempt -eq $maxAttempts) { return $false }
}
}
Start-Sleep -Milliseconds 25
}
return $false
}
function Get-CurrentActivePlan {
if (-not (Test-SafeActiveFile -AllowLink)) { return "" }
try {
@@ -305,9 +354,16 @@ if ($activeItem -and -not (Test-SafeActiveFile)) {
$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')
# file and caller-owned backup live beside the pointer, keeping replacement on
# the same filesystem. Naming the backup prevents ReplaceFile from inventing
# an unowned ~RF*.TMP path if its final rename fails.
$operationId = [guid]::NewGuid().ToString('N')
$tempFile = Join-Path $PlanRoot ('.active_plan.' + $operationId + '.tmp')
$backupFile = Join-Path $PlanRoot ('.active_plan.' + $operationId + '.replace-backup')
$createdTemp = $false
$failureMessage = ""
$backupResolved = $true
$replaced = $false
try {
$stream = [IO.File]::Open($tempFile, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
$createdTemp = $true
@@ -316,19 +372,48 @@ try {
$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)
$maxAttempts = 20
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
try {
$currentActive = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
if ($currentActive) {
if (-not (Test-SafeActiveFile)) {
throw [InvalidOperationException]::new("the active plan pointer became unsafe during replacement")
}
[IO.File]::Replace($tempFile, $ActiveFile, $backupFile)
} else {
[IO.File]::Move($tempFile, $ActiveFile)
}
$replaced = $true
break
} catch [IO.IOException] {
if (-not (Resolve-OwnedPointerBackup -BackupFile $backupFile)) {
throw [IO.IOException]::new("could not safely recover the active plan pointer after replacement failure")
}
if ($attempt -eq $maxAttempts) { throw }
Start-Sleep -Milliseconds 25
}
}
} catch {
Write-Error "Error: could not set the active plan pointer: $($_.Exception.Message)"
exit 1
$failureMessage = $_.Exception.Message
} finally {
if (-not (Resolve-OwnedPointerBackup -BackupFile $backupFile -ReplaceSucceeded:$replaced)) {
# The pointer was written; a leftover backup is clutter, not failure.
if ($replaced) { Write-Warning "could not remove the replacement backup $backupFile" }
else { $backupResolved = $false }
}
if ($createdTemp -and (Test-Path -LiteralPath $tempFile)) {
Remove-Item -LiteralPath $tempFile -Force -ErrorAction SilentlyContinue
}
}
if (-not $backupResolved) {
Write-Error "Error: could not safely recover the active plan pointer backup."
exit 1
}
if ($failureMessage) {
Write-Error "Error: could not set the active plan pointer: $failureMessage"
exit 1
}
Write-Output "Active plan set to: $PlanId"
Write-Output "Path: $PlanDir"
Write-Output ""
@@ -115,6 +115,46 @@ def apply_v3_mode(project_dir: Path, plan_dir: Path, mode: str) -> dict[str, Any
return {"marker": marker.strip(), "attestation": digest}
def _active_plan_pointer_error(planning_root: Path) -> str | None:
pointer = planning_root / ".active_plan"
if not pointer.exists() and not pointer.is_symlink():
return None
try:
stat = pointer.lstat()
resolved = pointer.resolve(strict=True)
resolved.relative_to(planning_root.resolve(strict=True))
except (OSError, ValueError):
return f"refusing to replace unsafe active plan pointer: {pointer}"
if pointer.is_symlink() or not pointer.is_file() or stat.st_nlink > 1:
return f"refusing to replace unsafe active plan pointer: {pointer}"
return None
def _write_active_plan(planning_root: Path, plan_id: str) -> str | None:
pointer = planning_root / ".active_plan"
unsafe = _active_plan_pointer_error(planning_root)
if unsafe:
return unsafe
temp = planning_root / f".active_plan.{os.getpid()}.{secrets.token_hex(4)}.tmp"
fd: int | None = None
try:
fd = os.open(temp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as handle:
fd = None
handle.write(plan_id + "\n")
os.replace(temp, pointer)
return None
except OSError as exc:
return f"could not safely update active plan pointer: {exc}"
finally:
if fd is not None:
os.close(fd)
try:
temp.unlink()
except FileNotFoundError:
pass
def init_plan(
project_dir: Path,
*,
@@ -147,8 +187,13 @@ def init_plan(
planning_root.mkdir(parents=True, exist_ok=True)
plan_id = f"{_dt.date.today().isoformat()}-{slug}"
plan_dir = planning_root / plan_id
pointer_error = _active_plan_pointer_error(planning_root)
if pointer_error:
return {"ok": False, "error": pointer_error, "project_dir": str(project_dir)}
plan_dir.mkdir(exist_ok=True)
(planning_root / ".active_plan").write_text(plan_id + "\n", encoding="utf-8")
write_error = _write_active_plan(planning_root, plan_id)
if write_error:
return {"ok": False, "error": write_error, "project_dir": str(project_dir)}
else:
plan_dir = project_dir
plan_id = "root"
+1 -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.20.5"
version: "3.21.0"
hermes:
tags: [planning, long-running-tasks, context-engineering, workflow]
---
@@ -128,9 +128,58 @@ function Test-SafeActiveFile {
$linked = ([string]$item.LinkType) -in @('SymbolicLink', 'Junction')
return -not $item.PSIsContainer -and
($AllowLink -or -not $linked) -and
($AllowLink -or -not $item.IsReadOnly) -and
(Test-WithinRoot $ActiveFile)
}
function Test-SafeOwnedBackupFile {
param([string]$Path)
$item = Get-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue
if (-not $item) { return $false }
$linked = ([string]$item.LinkType) -in @('SymbolicLink', 'Junction')
return -not $item.PSIsContainer -and -not $linked -and (Test-WithinRoot $Path)
}
function Resolve-OwnedPointerBackup {
param([string]$BackupFile, [switch]$ReplaceSucceeded)
# ReplaceFile can move the old destination before failing to move the new
# file. A caller-named backup makes that intermediate state attributable to
# this invocation, so recover or delete only this GUID path. Never glob for
# ~RF*.TMP: a concurrent selector may own those files.
$maxAttempts = 20
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
$backupItem = Get-Item -LiteralPath $BackupFile -Force -ErrorAction SilentlyContinue
if (-not $backupItem) { return $true }
# A failed inspection is transient under concurrent writers: retry.
if (-not (Test-SafeOwnedBackupFile $BackupFile)) { Start-Sleep -Milliseconds 25; continue }
$currentActive = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
# After our own successful replacement the backup holds a superseded
# value: only delete it, never move it back over a newer pointer.
if ($ReplaceSucceeded -or $currentActive) {
if (-not $ReplaceSucceeded -and -not (Test-SafeActiveFile)) { Start-Sleep -Milliseconds 25; continue }
try {
[IO.File]::Delete($BackupFile)
return $true
} catch {
if ($attempt -eq $maxAttempts) { return $false }
}
} else {
try {
[IO.File]::Move($BackupFile, $ActiveFile)
return $true
} catch {
# Another selector may have recreated the pointer between the
# absence check and Move. Reinspect before deciding whether the
# owned backup is now redundant.
if ($attempt -eq $maxAttempts) { return $false }
}
}
Start-Sleep -Milliseconds 25
}
return $false
}
function Get-CurrentActivePlan {
if (-not (Test-SafeActiveFile -AllowLink)) { return "" }
try {
@@ -305,9 +354,16 @@ if ($activeItem -and -not (Test-SafeActiveFile)) {
$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')
# file and caller-owned backup live beside the pointer, keeping replacement on
# the same filesystem. Naming the backup prevents ReplaceFile from inventing
# an unowned ~RF*.TMP path if its final rename fails.
$operationId = [guid]::NewGuid().ToString('N')
$tempFile = Join-Path $PlanRoot ('.active_plan.' + $operationId + '.tmp')
$backupFile = Join-Path $PlanRoot ('.active_plan.' + $operationId + '.replace-backup')
$createdTemp = $false
$failureMessage = ""
$backupResolved = $true
$replaced = $false
try {
$stream = [IO.File]::Open($tempFile, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
$createdTemp = $true
@@ -316,19 +372,48 @@ try {
$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)
$maxAttempts = 20
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
try {
$currentActive = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
if ($currentActive) {
if (-not (Test-SafeActiveFile)) {
throw [InvalidOperationException]::new("the active plan pointer became unsafe during replacement")
}
[IO.File]::Replace($tempFile, $ActiveFile, $backupFile)
} else {
[IO.File]::Move($tempFile, $ActiveFile)
}
$replaced = $true
break
} catch [IO.IOException] {
if (-not (Resolve-OwnedPointerBackup -BackupFile $backupFile)) {
throw [IO.IOException]::new("could not safely recover the active plan pointer after replacement failure")
}
if ($attempt -eq $maxAttempts) { throw }
Start-Sleep -Milliseconds 25
}
}
} catch {
Write-Error "Error: could not set the active plan pointer: $($_.Exception.Message)"
exit 1
$failureMessage = $_.Exception.Message
} finally {
if (-not (Resolve-OwnedPointerBackup -BackupFile $backupFile -ReplaceSucceeded:$replaced)) {
# The pointer was written; a leftover backup is clutter, not failure.
if ($replaced) { Write-Warning "could not remove the replacement backup $backupFile" }
else { $backupResolved = $false }
}
if ($createdTemp -and (Test-Path -LiteralPath $tempFile)) {
Remove-Item -LiteralPath $tempFile -Force -ErrorAction SilentlyContinue
}
}
if (-not $backupResolved) {
Write-Error "Error: could not safely recover the active plan pointer backup."
exit 1
}
if ($failureMessage) {
Write-Error "Error: could not set the active plan pointer: $failureMessage"
exit 1
}
Write-Output "Active plan set to: $PlanId"
Write-Output "Path: $PlanDir"
Write-Output ""
@@ -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.20.5"
version: "3.21.0"
---
@@ -128,9 +128,58 @@ function Test-SafeActiveFile {
$linked = ([string]$item.LinkType) -in @('SymbolicLink', 'Junction')
return -not $item.PSIsContainer -and
($AllowLink -or -not $linked) -and
($AllowLink -or -not $item.IsReadOnly) -and
(Test-WithinRoot $ActiveFile)
}
function Test-SafeOwnedBackupFile {
param([string]$Path)
$item = Get-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue
if (-not $item) { return $false }
$linked = ([string]$item.LinkType) -in @('SymbolicLink', 'Junction')
return -not $item.PSIsContainer -and -not $linked -and (Test-WithinRoot $Path)
}
function Resolve-OwnedPointerBackup {
param([string]$BackupFile, [switch]$ReplaceSucceeded)
# ReplaceFile can move the old destination before failing to move the new
# file. A caller-named backup makes that intermediate state attributable to
# this invocation, so recover or delete only this GUID path. Never glob for
# ~RF*.TMP: a concurrent selector may own those files.
$maxAttempts = 20
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
$backupItem = Get-Item -LiteralPath $BackupFile -Force -ErrorAction SilentlyContinue
if (-not $backupItem) { return $true }
# A failed inspection is transient under concurrent writers: retry.
if (-not (Test-SafeOwnedBackupFile $BackupFile)) { Start-Sleep -Milliseconds 25; continue }
$currentActive = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
# After our own successful replacement the backup holds a superseded
# value: only delete it, never move it back over a newer pointer.
if ($ReplaceSucceeded -or $currentActive) {
if (-not $ReplaceSucceeded -and -not (Test-SafeActiveFile)) { Start-Sleep -Milliseconds 25; continue }
try {
[IO.File]::Delete($BackupFile)
return $true
} catch {
if ($attempt -eq $maxAttempts) { return $false }
}
} else {
try {
[IO.File]::Move($BackupFile, $ActiveFile)
return $true
} catch {
# Another selector may have recreated the pointer between the
# absence check and Move. Reinspect before deciding whether the
# owned backup is now redundant.
if ($attempt -eq $maxAttempts) { return $false }
}
}
Start-Sleep -Milliseconds 25
}
return $false
}
function Get-CurrentActivePlan {
if (-not (Test-SafeActiveFile -AllowLink)) { return "" }
try {
@@ -305,9 +354,16 @@ if ($activeItem -and -not (Test-SafeActiveFile)) {
$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')
# file and caller-owned backup live beside the pointer, keeping replacement on
# the same filesystem. Naming the backup prevents ReplaceFile from inventing
# an unowned ~RF*.TMP path if its final rename fails.
$operationId = [guid]::NewGuid().ToString('N')
$tempFile = Join-Path $PlanRoot ('.active_plan.' + $operationId + '.tmp')
$backupFile = Join-Path $PlanRoot ('.active_plan.' + $operationId + '.replace-backup')
$createdTemp = $false
$failureMessage = ""
$backupResolved = $true
$replaced = $false
try {
$stream = [IO.File]::Open($tempFile, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
$createdTemp = $true
@@ -316,19 +372,48 @@ try {
$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)
$maxAttempts = 20
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
try {
$currentActive = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
if ($currentActive) {
if (-not (Test-SafeActiveFile)) {
throw [InvalidOperationException]::new("the active plan pointer became unsafe during replacement")
}
[IO.File]::Replace($tempFile, $ActiveFile, $backupFile)
} else {
[IO.File]::Move($tempFile, $ActiveFile)
}
$replaced = $true
break
} catch [IO.IOException] {
if (-not (Resolve-OwnedPointerBackup -BackupFile $backupFile)) {
throw [IO.IOException]::new("could not safely recover the active plan pointer after replacement failure")
}
if ($attempt -eq $maxAttempts) { throw }
Start-Sleep -Milliseconds 25
}
}
} catch {
Write-Error "Error: could not set the active plan pointer: $($_.Exception.Message)"
exit 1
$failureMessage = $_.Exception.Message
} finally {
if (-not (Resolve-OwnedPointerBackup -BackupFile $backupFile -ReplaceSucceeded:$replaced)) {
# The pointer was written; a leftover backup is clutter, not failure.
if ($replaced) { Write-Warning "could not remove the replacement backup $backupFile" }
else { $backupResolved = $false }
}
if ($createdTemp -and (Test-Path -LiteralPath $tempFile)) {
Remove-Item -LiteralPath $tempFile -Force -ErrorAction SilentlyContinue
}
}
if (-not $backupResolved) {
Write-Error "Error: could not safely recover the active plan pointer backup."
exit 1
}
if ($failureMessage) {
Write-Error "Error: could not set the active plan pointer: $failureMessage"
exit 1
}
Write-Output "Active plan set to: $PlanId"
Write-Output "Path: $PlanDir"
Write-Output ""
@@ -372,6 +372,56 @@ describe("init and status", () => {
expect(buildContext(root, dir)).toContain("[planning-with-files] plan: ")
})
it("updates a regular active plan pointer through a directory junction project root", () => {
const linkedRoot = `${root}-junction`
fs.symlinkSync(root, linkedRoot, "junction")
try {
const first = initPlan(linkedRoot, { name: "First" }, env)
expect(first.ok).toBe(true)
const second = initPlan(linkedRoot, { name: "Second" }, env)
expect(second.ok).toBe(true)
const pointer = path.join(root, ".planning", ".active_plan")
expect(fs.lstatSync(pointer).isFile()).toBe(true)
expect(fs.readFileSync(pointer, "utf8")).toBe(`${second.plan_id}\n`)
} finally {
fs.rmSync(linkedRoot, { recursive: true, force: true })
}
})
it("refuses a linked planning directory without changing its external pointer", () => {
const outside = `${root}-outside`
const planning = path.join(root, ".planning")
fs.mkdirSync(outside)
const pointer = path.join(outside, ".active_plan")
fs.writeFileSync(pointer, "external-plan\n")
fs.symlinkSync(outside, planning, "junction")
try {
const result = initPlan(root, { name: "Blocked" }, env)
expect(result.ok).toBe(false)
expect(result.error).toContain("unsafe active plan pointer")
expect(fs.readFileSync(pointer, "utf8")).toBe("external-plan\n")
expect(fs.readdirSync(outside)).toEqual([".active_plan"])
} finally {
fs.rmSync(planning, { recursive: true, force: true })
fs.rmSync(outside, { recursive: true, force: true })
}
})
it("refuses a hard-linked active plan pointer without modifying its sibling", () => {
const planning = path.join(root, ".planning")
fs.mkdirSync(planning, { recursive: true })
const sibling = path.join(root, "pointer-sibling.txt")
fs.writeFileSync(sibling, "existing-plan\n")
fs.linkSync(sibling, path.join(planning, ".active_plan"))
const result = initPlan(root, { name: "Night Run" }, env)
expect(result.ok).toBe(false)
expect(result.error).toContain("unsafe active plan pointer")
expect(fs.readFileSync(sibling, "utf8")).toBe("existing-plan\n")
expect(fs.readdirSync(planning)).toEqual([".active_plan"])
})
it("uses a real skill template when one is discoverable, and rejects unknown modes", () => {
const skill = path.join(root, ".agents", "skills", "planning-with-files", "templates")
fs.mkdirSync(skill, { recursive: true })
@@ -86,6 +86,40 @@ function writeText(target: string, text: string): void {
fs.writeFileSync(target, text, { encoding: "utf8" })
}
function activePlanPointerError(planningRoot: string): string | null {
const pointer = path.join(planningRoot, ".active_plan")
const realRoot = realpathOrNull(planningRoot)
if (!isRealDir(planningRoot) || !realRoot) return `refusing to replace unsafe active plan pointer: ${pointer}`
if (!fs.existsSync(pointer)) return null
const st = lstatSafe(pointer)
const real = realpathOrNull(pointer)
return !st || !st.isFile() || st.isSymbolicLink() || st.nlink > 1 || !real || !isInside(real, realRoot)
? `refusing to replace unsafe active plan pointer: ${pointer}`
: null
}
function writeActivePlan(planningRoot: string, planId: string): string | null {
const pointer = path.join(planningRoot, ".active_plan")
const unsafe = activePlanPointerError(planningRoot)
if (unsafe) return unsafe
const tmp = path.join(planningRoot, `.active_plan.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`)
let fd: number | null = null
try {
fd = fs.openSync(tmp, "wx")
fs.writeFileSync(fd, `${planId}\n`, { encoding: "utf8" })
fs.closeSync(fd)
fd = null
fs.renameSync(tmp, pointer)
return null
} catch (err) {
return `could not safely update active plan pointer: ${err instanceof Error ? err.message : String(err)}`
} finally {
if (fd !== null) fs.closeSync(fd)
try { fs.unlinkSync(tmp) } catch { /* already renamed or absent */ }
}
}
function realpathOrNull(target: string): string | null {
try {
return fs.realpathSync(target)
@@ -736,8 +770,11 @@ export function initPlan(
fs.mkdirSync(planningRoot, { recursive: true })
planId = `${new Date().toISOString().slice(0, 10)}-${slug}`
planDir = path.join(planningRoot, planId)
const pointerError = activePlanPointerError(planningRoot)
if (pointerError) return { ok: false, error: pointerError, project_dir: root }
fs.mkdirSync(planDir, { recursive: true })
writeText(path.join(planningRoot, ".active_plan"), `${planId}\n`)
const writeError = writeActivePlan(planningRoot, planId)
if (writeError) return { ok: false, error: writeError, project_dir: root }
}
const created = copyTemplates(planDir, templatesDir, template)
const result: InitResult = {
@@ -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.20.5"
version: "3.21.0"
---
@@ -128,9 +128,58 @@ function Test-SafeActiveFile {
$linked = ([string]$item.LinkType) -in @('SymbolicLink', 'Junction')
return -not $item.PSIsContainer -and
($AllowLink -or -not $linked) -and
($AllowLink -or -not $item.IsReadOnly) -and
(Test-WithinRoot $ActiveFile)
}
function Test-SafeOwnedBackupFile {
param([string]$Path)
$item = Get-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue
if (-not $item) { return $false }
$linked = ([string]$item.LinkType) -in @('SymbolicLink', 'Junction')
return -not $item.PSIsContainer -and -not $linked -and (Test-WithinRoot $Path)
}
function Resolve-OwnedPointerBackup {
param([string]$BackupFile, [switch]$ReplaceSucceeded)
# ReplaceFile can move the old destination before failing to move the new
# file. A caller-named backup makes that intermediate state attributable to
# this invocation, so recover or delete only this GUID path. Never glob for
# ~RF*.TMP: a concurrent selector may own those files.
$maxAttempts = 20
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
$backupItem = Get-Item -LiteralPath $BackupFile -Force -ErrorAction SilentlyContinue
if (-not $backupItem) { return $true }
# A failed inspection is transient under concurrent writers: retry.
if (-not (Test-SafeOwnedBackupFile $BackupFile)) { Start-Sleep -Milliseconds 25; continue }
$currentActive = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
# After our own successful replacement the backup holds a superseded
# value: only delete it, never move it back over a newer pointer.
if ($ReplaceSucceeded -or $currentActive) {
if (-not $ReplaceSucceeded -and -not (Test-SafeActiveFile)) { Start-Sleep -Milliseconds 25; continue }
try {
[IO.File]::Delete($BackupFile)
return $true
} catch {
if ($attempt -eq $maxAttempts) { return $false }
}
} else {
try {
[IO.File]::Move($BackupFile, $ActiveFile)
return $true
} catch {
# Another selector may have recreated the pointer between the
# absence check and Move. Reinspect before deciding whether the
# owned backup is now redundant.
if ($attempt -eq $maxAttempts) { return $false }
}
}
Start-Sleep -Milliseconds 25
}
return $false
}
function Get-CurrentActivePlan {
if (-not (Test-SafeActiveFile -AllowLink)) { return "" }
try {
@@ -305,9 +354,16 @@ if ($activeItem -and -not (Test-SafeActiveFile)) {
$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')
# file and caller-owned backup live beside the pointer, keeping replacement on
# the same filesystem. Naming the backup prevents ReplaceFile from inventing
# an unowned ~RF*.TMP path if its final rename fails.
$operationId = [guid]::NewGuid().ToString('N')
$tempFile = Join-Path $PlanRoot ('.active_plan.' + $operationId + '.tmp')
$backupFile = Join-Path $PlanRoot ('.active_plan.' + $operationId + '.replace-backup')
$createdTemp = $false
$failureMessage = ""
$backupResolved = $true
$replaced = $false
try {
$stream = [IO.File]::Open($tempFile, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
$createdTemp = $true
@@ -316,19 +372,48 @@ try {
$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)
$maxAttempts = 20
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
try {
$currentActive = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
if ($currentActive) {
if (-not (Test-SafeActiveFile)) {
throw [InvalidOperationException]::new("the active plan pointer became unsafe during replacement")
}
[IO.File]::Replace($tempFile, $ActiveFile, $backupFile)
} else {
[IO.File]::Move($tempFile, $ActiveFile)
}
$replaced = $true
break
} catch [IO.IOException] {
if (-not (Resolve-OwnedPointerBackup -BackupFile $backupFile)) {
throw [IO.IOException]::new("could not safely recover the active plan pointer after replacement failure")
}
if ($attempt -eq $maxAttempts) { throw }
Start-Sleep -Milliseconds 25
}
}
} catch {
Write-Error "Error: could not set the active plan pointer: $($_.Exception.Message)"
exit 1
$failureMessage = $_.Exception.Message
} finally {
if (-not (Resolve-OwnedPointerBackup -BackupFile $backupFile -ReplaceSucceeded:$replaced)) {
# The pointer was written; a leftover backup is clutter, not failure.
if ($replaced) { Write-Warning "could not remove the replacement backup $backupFile" }
else { $backupResolved = $false }
}
if ($createdTemp -and (Test-Path -LiteralPath $tempFile)) {
Remove-Item -LiteralPath $tempFile -Force -ErrorAction SilentlyContinue
}
}
if (-not $backupResolved) {
Write-Error "Error: could not safely recover the active plan pointer backup."
exit 1
}
if ($failureMessage) {
Write-Error "Error: could not set the active plan pointer: $failureMessage"
exit 1
}
Write-Output "Active plan set to: $PlanId"
Write-Output "Path: $PlanDir"
Write-Output ""
File diff suppressed because it is too large Load Diff
+11 -9
View File
@@ -1,5 +1,5 @@
---
name: pi-planning-with-files
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."
user-invocable: true
allowed-tools: "Read Write Edit Bash Glob Grep"
@@ -7,26 +7,28 @@ hooks:
UserPromptSubmit:
- 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=userprompt; exit 0"
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=userprompt; exit 0"
PreToolUse:
- matcher: "Write|Edit|Bash|Read|Glob|Grep"
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=pretool; exit 0"
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=pretool; exit 0"
PostToolUse:
- matcher: "Write|Edit"
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=posttool; exit 0"
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=posttool; exit 0"
Stop:
- 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=stop; exit 0"
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=stop; exit 0"
PreCompact:
- matcher: "*"
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"
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.21.0"
---
# Planning with Files
@@ -221,7 +223,7 @@ 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.
@@ -284,11 +286,11 @@ Not every install path ships every surface in this section. Two distinct install
| `/plugin marketplace add OthmanAdi/planning-with-files` then `/plugin install` | SKILL.md, scripts, templates, **plus `commands/` folder** | Yes, as `/plan-goal` and `/plan-loop` |
| `npx skills add OthmanAdi/planning-with-files` (or ClawHub) | SKILL.md, scripts, templates only | No, follow the manual fallback below |
The PreCompact hook is registered in the SKILL.md frontmatter and works for both routes. The `/plan-goal` and `/plan-loop` slash commands live in `commands/` at the repo root, which only the plugin route copies into `~/.claude/plugins/marketplaces/`. Skill-only installs land at `~/.claude/skills/planning-with-files/` and do not see `commands/`.
Plugin installs register six lifecycle events from `hooks/hooks.json`, including quiet `SessionStart` recovery. Standalone skill installs register the five hooks in this SKILL.md frontmatter only after the skill is invoked for that session, so they have no startup recovery. The `/plan-goal` and `/plan-loop` slash commands live in `commands/` at the repository root and are available from the versioned plugin cache. Skill-only installs land at `~/.claude/skills/planning-with-files/` and do not include `commands/`.
The standalone `scripts/skill-hook.sh` reads the host's JSON session identity. UserPromptSubmit emits plain context; PreToolUse and PostToolUse emit the event's `additionalContext` JSON. The progress reminder fires at most once per turn when a usable session identity and private cache are available, and repeats when those are unavailable. All five events follow the same plan selection and opt-out checks.
Both slash commands also carry `disable-model-invocation: true`, which means the model will not auto-trigger them. You type them. Per known Claude Code behavior (anthropics/claude-code issues #26251, #41417), some sessions interpret `disable-model-invocation: true` as "I cannot use the Skill tool for this entry at all" and refuse to fire even when you type the slash. If that happens, the manual fallback below produces the same effect.
Both slash commands carry `disable-model-invocation: true`, so invoke them explicitly. If a command is unavailable on a skill-only install, the manual fallback below produces the same planning-file result.
### PreCompact hook (auto)
+5 -5
View File
@@ -1,10 +1,9 @@
{
"name": "planning-with-files",
"version": "3.20.5",
"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.",
"version": "3.21.0",
"description": "Official Planning with Files skill for AI coding agents. Keeps persistent project planning files and injects selected plan context. Automatic recovery reads project files only; explicit local session catchup has no network upload path. Includes scripts, templates, and a Pi Coding Agent extension.",
"keywords": [
"pi-package",
"pi-skill",
"planning-with-files",
"planning",
"manus",
"agent",
@@ -14,7 +13,8 @@
"coding-agent",
"context-engineering",
"session-recovery",
"long-running-agents"
"long-running-agents",
"pi-coding-agent"
],
"pi": {
"skills": [
@@ -6,16 +6,19 @@
# attested hash, surfacing a "[PLAN TAMPERED]" warning instead.
#
# Resolution:
# 1. $PLAN_ID env var → ./.planning/$PLAN_ID/
# 2. ./.planning/.active_plan
# 3. Newest ./.planning/<dir>/ by mtime
# 4. Current directory when it is .planning/<valid-slug>/
# 5. Legacy ./task_plan.md at project root
# 1. --target root or <plan-id> → root or named plan under $PWF_PLAN_ROOT (default: cwd)
# 2. $PLAN_ID env var → ./.planning/$PLAN_ID/
# 3. ./.planning/.active_plan
# 4. Newest ./.planning/<dir>/ by mtime
# 5. Current directory when it is .planning/<valid-slug>/
# 6. Legacy ./task_plan.md at project root
#
# Usage:
# sh scripts/attest-plan.sh # attest the active plan
# sh scripts/attest-plan.sh --show # print the stored hash
# sh scripts/attest-plan.sh --clear # remove the attestation (re-open the plan)
# sh scripts/attest-plan.sh --target root # attest the legacy root plan
# sh scripts/attest-plan.sh --target <plan-id> # attest one named plan
set -u
@@ -42,6 +45,39 @@ resolve_from_slug_cwd() {
}
resolve_plan_file() {
if [ -n "${target:-}" ]; then
if [ "${target}" = "root" ]; then
target_root="."
if [ -n "${PWF_PLAN_ROOT:-}" ]; then
# Match the shared resolver's supported absolute local pins.
# A valid project-root junction is intentional, so resolve it.
case "${PWF_PLAN_ROOT}" in
\\\\*|//*|[A-Za-z]:[!\\/]*) return 1 ;;
/*|[A-Za-z]:[\\/]*) ;;
*) return 1 ;;
esac
target_root="$(cd "${PWF_PLAN_ROOT}" 2>/dev/null && pwd -P)" || return 1
fi
[ ! -L "${target_root}/task_plan.md" ] || return 1
[ -f "${target_root}/task_plan.md" ] || return 1
printf "%s\n" "${target_root}/task_plan.md"
return 0
fi
slug_is_valid "${target}" || return 1
# Reuse the binding, project-pin and containment rules. A flag replaces
# PLAN_ID for this call only; it does not change the shared pointer.
[ -f "${RESOLVER}" ] || return 1
target_dir="$(PLAN_ID="${target}" sh "${RESOLVER}" 2>/dev/null)" || return 1
[ -n "${target_dir}" ] || return 1
# Use the shell's physical path spelling: native Windows backslashes
# make sha256sum prefix its output with an escaped-filename marker.
target_dir="$(cd "${target_dir}" 2>/dev/null && pwd -P)" || return 1
[ ! -L "${target_dir}/task_plan.md" ] || return 1
[ -f "${target_dir}/task_plan.md" ] || return 1
printf "%s\n" "${target_dir}/task_plan.md"
return 0
fi
plan_dir=""
if [ -f "${RESOLVER}" ]; then
plan_dir="$(sh "${RESOLVER}" 2>/dev/null)"
@@ -80,7 +116,9 @@ resolve_plan_file() {
attestation_path_for() {
plan_file="$1"
plan_dir="$(dirname "${plan_file}")"
if [ "${plan_dir}" = "." ]; then
if [ "${target:-}" = "root" ]; then
printf "%s\n" "${plan_dir}/.plan-attestation"
elif [ "${plan_dir}" = "." ]; then
# Legacy mode: store at project root.
printf "%s\n" "./.plan-attestation"
else
@@ -101,14 +139,21 @@ compute_hash() {
}
mode="attest"
target=""
usage_error() {
printf "Usage: %s [--show|--clear|--target root|<plan-id>]\n" "$0" >&2
exit 2
}
case "${1:-}" in
--show) mode="show" ;;
--clear) mode="clear" ;;
"") mode="attest" ;;
*)
printf "Usage: %s [--show|--clear]\n" "$0" >&2
exit 2
--show) [ "$#" -eq 1 ] || usage_error; mode="show" ;;
--clear) [ "$#" -eq 1 ] || usage_error; mode="clear" ;;
--target)
[ "$#" -eq 2 ] || usage_error
[ -n "$2" ] || usage_error
target="$2"
;;
"") [ "$#" -eq 0 ] || usage_error ;;
*) usage_error ;;
esac
plan_file="$(resolve_plan_file)" || {
@@ -117,7 +162,9 @@ plan_file="$(resolve_plan_file)" || {
# a mistyped PLAN_ID attested a DIFFERENT plan at rc=0, and an operator
# who now sees a generic not-found is likely to go looking for the wrong
# problem. The selectors are bindings, so say which one refused.
if [ -n "${PLAN_ID:-}" ]; then
if [ -n "${target:-}" ]; then
printf "[plan-attest] --target %s did not resolve to a plan. An explicit target is a binding: nothing was attested and no other plan was substituted.\n" "${target}" >&2
elif [ -n "${PLAN_ID:-}" ]; then
printf "[plan-attest] PLAN_ID=%s names no plan directory under .planning. An explicit selector is a binding: nothing was attested and no other plan was substituted.\n" "${PLAN_ID}" >&2
elif [ -n "${PWF_PLAN_ROOT:-}" ]; then
printf "[plan-attest] PWF_PLAN_ROOT=%s did not resolve to a project root holding a plan. An explicit pin is a binding: nothing was attested and no other plan was substituted.\n" "${PWF_PLAN_ROOT}" >&2
@@ -156,6 +203,8 @@ case "${mode}" in
fi
;;
attest)
printf "Plan: %s\n" "${plan_file}"
printf "Attestation: %s\n" "${attestation_file}"
hash_val="$(compute_hash "${plan_file}")" || exit 1
# v2.40: protect the write with an advisory flock when available so
@@ -51,6 +51,8 @@ if ($PlanFile -ne "") {
}
if (-not (Test-Path $PlanFile)) {
# Automatic gate checks have nothing to report without a plan; the explicit report keeps its notice.
if ($Gate) { exit 0 }
Write-Host '[planning-with-files] No task_plan.md found -- no active planning session.'
exit 0
}
@@ -79,6 +79,9 @@ else
fi
if [ ! -f "$PLAN_FILE" ]; then
# Automatic gate checks have nothing to report without a plan (header:
# "plans stay silent in --gate mode"); the explicit report keeps its notice.
[ "$GATE" -eq 1 ] && exit 0
echo "[planning-with-files] No task_plan.md found — no active planning session."
exit 0
fi
@@ -18,6 +18,27 @@ param(
[switch]$Gated
)
# Windows PowerShell 5.1 can silently relocate a -File invocation when the
# inherited cwd contains wildcard characters such as [ or ]. Recover the
# physical cwd for a direct invocation before any project-relative paths are
# resolved.
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)) {
Set-Location -LiteralPath ([Environment]::CurrentDirectory)
}
break
}
}
}
$DATE = Get-Date -Format "yyyy-MM-dd"
# Resolve v3 opt-in mode. -Gated implies autonomous and is the stronger marker.
@@ -331,13 +352,39 @@ if ($UsePlanDir) {
# Activate the named plan only after all three planning files are ready.
# This matches init-session.sh and prevents a failed initialization from
# leaving .active_plan pointed at a partial plan directory.
$global:LASTEXITCODE = 0
try {
& $PlanSelector $PlanId *> $null
} catch {
$global:LASTEXITCODE = 1
$pointerSet = $false
for ($attempt = 1; $attempt -le 5; $attempt++) {
$global:LASTEXITCODE = 0
try {
$selectorResult = & $PlanSelector $PlanId 2>&1
} catch {
$selectorResult = $_
$global:LASTEXITCODE = 1
}
if ($LASTEXITCODE -eq 0) {
$pointerSet = $true
break
}
# Another writer can replace the pointer between the selector's
# Get-Item and final-path check. Retry only that transient result.
$transientPointerRace = $selectorResult -is [System.Management.Automation.ErrorRecord] -and
$selectorResult.Exception.Message -in @(
'Error: could not set the active plan pointer: the active plan pointer became unsafe during replacement',
'Error: the active plan pointer must be a regular file within the project.'
)
if ($attempt -eq 5 -or -not $transientPointerRace) {
break
}
Start-Sleep -Milliseconds 50
$global:LASTEXITCODE = 0
try {
& $PlanSelector -VerifyRoot *> $null
} catch {
$global:LASTEXITCODE = 1
}
if ($LASTEXITCODE -ne 0) { break }
}
if ($LASTEXITCODE -ne 0) {
if (-not $pointerSet) {
Write-Error "Error: could not safely update the active plan pointer at $(Join-Path $PlanningRoot '.active_plan')."
exit 1
}
@@ -7,9 +7,10 @@
The ONLY sanctioned concurrent-safe writer of task_plan.md status lines. The
orchestrator owns task_plan.md; workers NEVER edit it directly. The edit is
a read-modify-write under the portable
<plan-dir>\.pwf-locks\phase-status.lock directory lock, with an atomic
temp-file + move swap so a torn write can never leave a half-rewritten plan
on disk (architecture C4).
<plan-dir>\.pwf-locks\phase-status.lock namespace. Ownership is granted by
atomically creating its .owner file, rather than trusting an external mkdir
exit status. The plan rewrite uses an atomic temp-file + move swap so a torn
write can never leave a half-rewritten plan on disk (architecture C4).
Editing task_plan.md changes its SHA, so the orchestrator must re-attest at
phase boundaries (see attest-plan.ps1).
@@ -102,18 +103,44 @@ function Enter-PwfDirectoryLock {
$wait = [Diagnostics.Stopwatch]::StartNew()
while ($wait.Elapsed.TotalSeconds -lt 5) {
$createdByUs = $false
try {
New-Item -Path $LockDir -ItemType Directory -ErrorAction Stop | Out-Null
$createdByUs = $true
[System.IO.File]::WriteAllText($ownerFile, $token + "`n", $utf8NoBom)
[void][System.IO.Directory]::CreateDirectory($LockDir)
} catch {
Start-Sleep -Milliseconds 100
continue
}
$stream = $null
$createdOwner = $false
$claimed = $false
try {
# FileMode.CreateNew is the cross-runtime ownership decision. It
# fails atomically when either the shell or PowerShell writer has
# already claimed .owner, even if both observed mkdir success.
$stream = [System.IO.File]::Open(
$ownerFile,
[System.IO.FileMode]::CreateNew,
[System.IO.FileAccess]::Write,
[System.IO.FileShare]::None
)
$createdOwner = $true
$bytes = $utf8NoBom.GetBytes($token + "`n")
$stream.Write($bytes, 0, $bytes.Length)
$stream.Flush()
$claimed = $true
return [PSCustomObject]@{
Directory = $LockDir
OwnerFile = $ownerFile
Token = $token
}
} catch {
if ($createdByUs) {
# Existing ownership is the normal contention path. Other failures
# remain fail-closed and are retried only within the bounded wait.
} finally {
if ($stream) {
$stream.Dispose()
}
if ($createdOwner -and -not $claimed) {
try {
if ([System.IO.File]::Exists($ownerFile)) {
$ownerValue = [System.IO.File]::ReadAllText($ownerFile).Trim()
@@ -121,13 +148,12 @@ function Enter-PwfDirectoryLock {
[System.IO.File]::Delete($ownerFile)
}
}
[System.IO.Directory]::Delete($LockDir, $false)
} catch {
# Leave any directory we cannot prove is still ours intact.
# Leave any owner file we cannot prove is still ours intact.
}
}
Start-Sleep -Milliseconds 100
}
Start-Sleep -Milliseconds 100
}
return $null
@@ -174,7 +200,7 @@ $planDir = Split-Path -Parent $planFile
$lockRoot = Join-Path $planDir ".pwf-locks"
$lockDir = Join-Path $lockRoot "phase-status.lock"
# Atomic directory creation is the common lock primitive used by both the sh
# Exclusive .owner creation is the common lock primitive used by both the sh
# and PowerShell implementations. Failure to acquire within about five seconds
# is fail-closed: no plan read/rewrite is attempted.
$lock = Enter-PwfDirectoryLock -LockRoot $lockRoot -LockDir $lockDir
@@ -4,9 +4,12 @@
# This is the ONLY sanctioned concurrent-safe writer of task_plan.md status
# lines. The orchestrator owns task_plan.md; workers NEVER edit it directly.
# All status edits go through this read-modify-write under the portable
# <plan-dir>/.pwf-locks/phase-status.lock directory lock, with an atomic
# temp-file + mv swap so a torn write can never leave a half-rewritten plan on
# disk (architecture C4).
# <plan-dir>/.pwf-locks/phase-status.lock namespace. Lock ownership is the
# atomic no-clobber creation of its .owner file; the directory's mkdir exit
# status is only provisional because some Windows-native implementations can
# report success to two simultaneous creators. The plan rewrite still uses an
# atomic temp-file + mv swap so a torn write can never leave a half-rewritten
# plan on disk (architecture C4).
#
# Note: editing task_plan.md changes its SHA, so the orchestrator must
# re-attest at phase boundaries (see attest-plan.sh).
@@ -106,6 +109,22 @@ release_lock() {
LOCK_ACQUIRED=0
}
claim_lock_owner() {
owner_file="${LOCK_DIR}/.owner"
# POSIX noclobber makes this redirection an exclusive create. This is the
# actual ownership decision: a Windows-native mkdir may report success
# after another process won the directory-creation race (issue #281).
if (
umask 077
set -C
printf '%s\n' "${LOCK_TOKEN}" > "${owner_file}"
) 2>/dev/null; then
LOCK_ACQUIRED=1
return 0
fi
return 1
}
acquire_lock() {
mkdir -p "${LOCK_ROOT}" 2>/dev/null || {
printf "[phase-status] Cannot create lock root %s.\n" "${LOCK_ROOT}" >&2
@@ -114,7 +133,13 @@ acquire_lock() {
LOCK_TOKEN="phase-status-$$-$(date +%s 2>/dev/null || printf 0)"
started_at="$(date +%s 2>/dev/null || printf 0)"
attempts=0
while ! mkdir "${LOCK_DIR}" 2>/dev/null; do
while :; do
# Directory creation is intentionally idempotent. The exclusive owner
# file below, not this external utility's exit status, grants the lock.
mkdir "${LOCK_DIR}" 2>/dev/null || true
if [ -d "${LOCK_DIR}" ] && claim_lock_owner; then
return 0
fi
attempts=$((attempts + 1))
now="$(date +%s 2>/dev/null || printf 0)"
if { [ "${started_at}" -gt 0 ] 2>/dev/null \
@@ -125,13 +150,6 @@ acquire_lock() {
fi
sleep 0.1
done
if ! printf '%s\n' "${LOCK_TOKEN}" > "${LOCK_DIR}/.owner" 2>/dev/null; then
rmdir "${LOCK_DIR}" 2>/dev/null || true
printf "[phase-status] Cannot record lock ownership in %s.\n" "${LOCK_DIR}" >&2
return 1
fi
LOCK_ACQUIRED=1
return 0
}
trap 'release_lock' EXIT
@@ -128,9 +128,58 @@ function Test-SafeActiveFile {
$linked = ([string]$item.LinkType) -in @('SymbolicLink', 'Junction')
return -not $item.PSIsContainer -and
($AllowLink -or -not $linked) -and
($AllowLink -or -not $item.IsReadOnly) -and
(Test-WithinRoot $ActiveFile)
}
function Test-SafeOwnedBackupFile {
param([string]$Path)
$item = Get-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue
if (-not $item) { return $false }
$linked = ([string]$item.LinkType) -in @('SymbolicLink', 'Junction')
return -not $item.PSIsContainer -and -not $linked -and (Test-WithinRoot $Path)
}
function Resolve-OwnedPointerBackup {
param([string]$BackupFile, [switch]$ReplaceSucceeded)
# ReplaceFile can move the old destination before failing to move the new
# file. A caller-named backup makes that intermediate state attributable to
# this invocation, so recover or delete only this GUID path. Never glob for
# ~RF*.TMP: a concurrent selector may own those files.
$maxAttempts = 20
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
$backupItem = Get-Item -LiteralPath $BackupFile -Force -ErrorAction SilentlyContinue
if (-not $backupItem) { return $true }
# A failed inspection is transient under concurrent writers: retry.
if (-not (Test-SafeOwnedBackupFile $BackupFile)) { Start-Sleep -Milliseconds 25; continue }
$currentActive = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
# After our own successful replacement the backup holds a superseded
# value: only delete it, never move it back over a newer pointer.
if ($ReplaceSucceeded -or $currentActive) {
if (-not $ReplaceSucceeded -and -not (Test-SafeActiveFile)) { Start-Sleep -Milliseconds 25; continue }
try {
[IO.File]::Delete($BackupFile)
return $true
} catch {
if ($attempt -eq $maxAttempts) { return $false }
}
} else {
try {
[IO.File]::Move($BackupFile, $ActiveFile)
return $true
} catch {
# Another selector may have recreated the pointer between the
# absence check and Move. Reinspect before deciding whether the
# owned backup is now redundant.
if ($attempt -eq $maxAttempts) { return $false }
}
}
Start-Sleep -Milliseconds 25
}
return $false
}
function Get-CurrentActivePlan {
if (-not (Test-SafeActiveFile -AllowLink)) { return "" }
try {
@@ -305,9 +354,16 @@ if ($activeItem -and -not (Test-SafeActiveFile)) {
$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')
# file and caller-owned backup live beside the pointer, keeping replacement on
# the same filesystem. Naming the backup prevents ReplaceFile from inventing
# an unowned ~RF*.TMP path if its final rename fails.
$operationId = [guid]::NewGuid().ToString('N')
$tempFile = Join-Path $PlanRoot ('.active_plan.' + $operationId + '.tmp')
$backupFile = Join-Path $PlanRoot ('.active_plan.' + $operationId + '.replace-backup')
$createdTemp = $false
$failureMessage = ""
$backupResolved = $true
$replaced = $false
try {
$stream = [IO.File]::Open($tempFile, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
$createdTemp = $true
@@ -316,19 +372,48 @@ try {
$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)
$maxAttempts = 20
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
try {
$currentActive = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
if ($currentActive) {
if (-not (Test-SafeActiveFile)) {
throw [InvalidOperationException]::new("the active plan pointer became unsafe during replacement")
}
[IO.File]::Replace($tempFile, $ActiveFile, $backupFile)
} else {
[IO.File]::Move($tempFile, $ActiveFile)
}
$replaced = $true
break
} catch [IO.IOException] {
if (-not (Resolve-OwnedPointerBackup -BackupFile $backupFile)) {
throw [IO.IOException]::new("could not safely recover the active plan pointer after replacement failure")
}
if ($attempt -eq $maxAttempts) { throw }
Start-Sleep -Milliseconds 25
}
}
} catch {
Write-Error "Error: could not set the active plan pointer: $($_.Exception.Message)"
exit 1
$failureMessage = $_.Exception.Message
} finally {
if (-not (Resolve-OwnedPointerBackup -BackupFile $backupFile -ReplaceSucceeded:$replaced)) {
# The pointer was written; a leftover backup is clutter, not failure.
if ($replaced) { Write-Warning "could not remove the replacement backup $backupFile" }
else { $backupResolved = $false }
}
if ($createdTemp -and (Test-Path -LiteralPath $tempFile)) {
Remove-Item -LiteralPath $tempFile -Force -ErrorAction SilentlyContinue
}
}
if (-not $backupResolved) {
Write-Error "Error: could not safely recover the active plan pointer backup."
exit 1
}
if ($failureMessage) {
Write-Error "Error: could not set the active plan pointer: $failureMessage"
exit 1
}
Write-Output "Active plan set to: $PlanId"
Write-Output "Path: $PlanDir"
Write-Output ""
+58
View File
@@ -4,6 +4,64 @@ All notable changes to this project will be documented in this file.
## [Unreleased]
## [3.21.0] - 2026-09-27
### Added
- `attest-plan.sh --target root` attests the project roadmap while a named plan remains active. `--target <plan-id>` selects a named plan through the existing resolver, honors `PWF_PLAN_ROOT`, and rejects invalid or linked targets without falling back. Attestation writes print the resolved plan and hash-file paths first (#296, PR #297).
### Fixed
- PowerShell named-plan initialization retries the transient active-pointer pre-check failure that can occur during a concurrent replacement. Every retry retains the root and pointer safety checks, and attempts remain bounded (#294, PR #295).
- Explicit attestation targets reject empty values and unsupported trailing arguments. Named targets preserve containment checks and normalize validated Windows paths before hashing (#296, PR #297).
### Thanks
- @tayfuryldz, for the PowerShell pre-check retry and explicit attestation target, with regression coverage (#294, #296, PRs #295 and #297).
## [3.20.8] - 2026-09-25
### Fixed
- The automatic Stop check is silent when no plan exists. In every Claude Code session without a `task_plan.md`, each reply ended with "No task_plan.md found" because the no-plan branch of `check-complete --gate` printed its notice and the hook surfaced it as a system message. The gate path now exits quietly there, and the explicit report (`check-complete` without `--gate`) keeps the notice (#288, PR #289).
- PowerShell active-pointer replacement names its own backup file for `ReplaceFile`. When the final replacement fails after the old pointer was moved aside, the selector moves it back, or deletes only its own backup when another selector has already written a safe pointer. It no longer leaves an unowned `.active_plan~RF*.TMP` behind and never deletes one it did not create. After a successful switch the backup only holds the superseded value, so it is deleted without being moved back, and a backup that cannot be removed produces a warning instead of a failed exit for a pointer that was already written (#254, PR #290).
- Cursor hooks follow Cursor's current hook schema. Cursor has no `userPromptSubmit` event, so the plan was never injected; plan context now arrives through `sessionStart` `additional_context`. `preToolUse` answers `{"permission":"allow"}`, the progress reminder is returned as `postToolUse` `additional_context`, failure paths print valid JSON, and the Windows manifest starts PowerShell with `-NoProfile`. The shell session-start hook runs Python in isolated mode and falls back from `python3` to `python`. Cursor Cloud Agents do not run `sessionStart` (#262, PR #291).
- Gemini CLI hooks use the output fields Gemini reads. Plan context moves from `BeforeTool`, whose `systemMessage` is shown only to the user, and `BeforeModel`, whose top-level `additionalContext` is ignored, to `BeforeAgent` `hookSpecificOutput.additionalContext`; the `AfterTool` reminder moves into `hookSpecificOutput` as well. The skill metadata lists the registered events (#292, PR #293).
- The Cursor and Gemini hook scripts are executable. Gemini runs each hook as `bash -c <path>` and Cursor requires executable script hooks, but the four Gemini hooks and the Cursor `pre-tool-use`, `post-tool-use` and `stop` hooks were committed as 100644, so they could not run from a macOS or Linux clone. A test now checks the git mode of every script the two manifests run by path. On macOS and Linux this also turns on the documented Cursor stop hook: while the root plan has incomplete phases it asks Cursor to continue, at most three times per stop (`loop_limit`), without a `.mode` opt-in, as the Windows route already did.
- The Cursor stop hook is silent when every phase is complete. Cursor submits a stop hook's `followup_message` as the next user message, and the hook returned an "ALL PHASES COMPLETE" message for a finished plan, so each stop in such a project triggered up to three automatic follow-up turns on the Windows route. An incomplete plan still asks Cursor to continue.
### Changed
- Projects that copied the Cursor or Gemini adapters need the new manifest together with the new scripts: `.cursor/hooks.json` and `.cursor/hooks.windows.json` register `sessionStart` with `session-start.sh` or `session-start.ps1`, and `.gemini/settings.json` registers `BeforeAgent` with `before-agent.sh`, which replaces `before-tool.sh` and `before-model.sh`.
### Thanks
- @mmychu, for tracing the per-reply Stop notice to the no-plan branch of `check-complete --gate` and fixing every copy (#288, PR #289).
- @kuei51307-hub, for the caller-owned `ReplaceFile` backup with pointer recovery and its failure-path regression (#254, PR #290).
- @SomSamantray, for moving the Cursor hooks to the current schema (#262, PR #291).
- @ShaunLinTW, for reporting and fixing the Gemini hook output schema (#292, PR #293).
## [3.20.7] - 2026-09-23
### Fixed
- The npm package description now discloses selected project planning context and the absence of a network upload path. This restores the public-capability metadata check that failed in v3.20.6 CI.
- The contributor portrait grid uses the current profile for @Fat-Jan and stable identicons linked to archived credits for two unavailable accounts.
- The full npm README points to the v3.20.7 repository files and includes the corrected contributor portraits.
## [3.20.6] - 2026-09-23
### Added
- The README ends with a linked portrait grid of credited contributors, excluding the maintainer.
### Fixed
- Phase-status locking now grants ownership through exclusive `.owner` creation instead of relying on `mkdir` exit status. This prevents a lost update when a Windows-native `mkdir` reports success to two writers (#281, PR #282).
- Hermes rejects linked active-plan pointers and replaces the pointer through an exclusive temporary file, preserving hard-linked sibling content (#259, PR #284).
- OpenCode and the DSH core reject linked active-plan pointers and replace the pointer without truncating a hard-linked sibling. A linked `.planning` directory is rejected while a project root reached through a junction remains usable (#260, PR #283).
- PowerShell named-plan initialization rejects a read-only active pointer before creating the new plan directory (#253, PR #285).
- PowerShell initialization recovers a project working directory containing `[` or `]` under Windows PowerShell 5.1 (#256, PR #286).
- PowerShell active-pointer replacement retries transient concurrent I/O and pointer-inspection races while rechecking root and pointer safety. Cleanup of a `ReplaceFile` artifact after final failure remains open in #254 (PR #287).
### Changed
- The `planning-with-files` npm package ships the canonical skill and displays the full official repository README. Repository-relative images and document links resolve to the v3.20.6 release while the Pi integration remains bundled.
### Thanks
- @kuei51307-hub, for the exclusive phase-status lock claim and regression in #282.
- @TayfurYldz, for the OpenCode, DSH, Hermes and PowerShell pointer fixes in #283, #284, #285, #286 and #287.
## [3.20.5] - 2026-09-21
### Fixed
+2 -2
View File
@@ -7,8 +7,8 @@ authors:
- family-names: Adi
given-names: Ahmad Othman Ammar
alias: OthmanAdi
version: "3.20.5"
date-released: "2026-09-21"
version: "3.21.0"
date-released: "2026-09-27"
license: MIT
url: "https://github.com/OthmanAdi/planning-with-files"
repository-code: "https://github.com/OthmanAdi/planning-with-files"
+18 -6
View File
@@ -106,22 +106,32 @@ These amazing people have contributed code, documentation, or significant improv
### Other Contributors
- **[Som Samantray](https://github.com/SomSamantray)**, [PR #291](https://github.com/OthmanAdi/planning-with-files/pull/291)
- Moved the Cursor hooks to Cursor's current hook schema: plan context through `sessionStart` `additional_context`, `preToolUse` answering with `permission`, the progress reminder as `postToolUse` `additional_context`, valid JSON on every failure path and `-NoProfile` for the PowerShell hooks, with schema tests for the shell and PowerShell hooks.
- **[@mmychu](https://github.com/mmychu)**, [PR #289](https://github.com/OthmanAdi/planning-with-files/pull/289)
- Made the automatic Stop check silent when no plan exists, so sessions that never opted into planning no longer end every reply with a notice, with regressions across both checkers and the three hook routes.
- **[@ericshunhinglee-cloud](https://github.com/ericshunhinglee-cloud)**, [Issue #272](https://github.com/OthmanAdi/planning-with-files/issues/272)
- Reported and traced the Hermes 0.21.3 first-turn `TERMINAL_CWD` rewrite that made the Hermes plugin resolve the home directory as the project root, with a setter stack trace, on-the-wire token evidence and the upstream cross-references.
- **[Shaun Lin](https://github.com/ShaunLinTW)**, [PR #245](https://github.com/OthmanAdi/planning-with-files/pull/245), [PR #247](https://github.com/OthmanAdi/planning-with-files/pull/247), [PR #263](https://github.com/OthmanAdi/planning-with-files/pull/263), [PR #271](https://github.com/OthmanAdi/planning-with-files/pull/271), [PR #280](https://github.com/OthmanAdi/planning-with-files/pull/280)
- **[Shaun Lin](https://github.com/ShaunLinTW)**, [PR #245](https://github.com/OthmanAdi/planning-with-files/pull/245), [PR #247](https://github.com/OthmanAdi/planning-with-files/pull/247), [PR #263](https://github.com/OthmanAdi/planning-with-files/pull/263), [PR #271](https://github.com/OthmanAdi/planning-with-files/pull/271), [PR #280](https://github.com/OthmanAdi/planning-with-files/pull/280), [PR #293](https://github.com/OthmanAdi/planning-with-files/pull/293)
- Isolated Python calls in the Codex, Gemini, and GitHub Copilot shell adapters and added regression coverage for project-local import shadowing.
- Added named-plan slug mode to the PowerShell initializer with root-mode policy inheritance, host-aware attestation, and Windows PowerShell regression coverage.
- Made the OpenCode session catchup adapter skip malformed part rows instead of raising, with regression coverage for the string-state and invalid-JSON shapes.
- Made the shell several-plans counters skip symlinked and junctioned plan directories so they match the Hermes plugin, with a differential regression including the ambiguity probe.
- Made denied PowerShell planning-file writes fail explicitly and deferred named-plan activation until all three files are ready, with real Windows ACL regression coverage.
- Reported and fixed the Gemini CLI hook output schema: plan context through `BeforeAgent` and the `AfterTool` reminder through `hookSpecificOutput.additionalContext`, with contract tests for the registered events and output shapes.
- **[@TayfurYldz](https://github.com/TayfurYldz)**, [PR #265](https://github.com/OthmanAdi/planning-with-files/pull/265), [PR #266](https://github.com/OthmanAdi/planning-with-files/pull/266), [PR #273](https://github.com/OthmanAdi/planning-with-files/pull/273)
- **[@TayfurYldz](https://github.com/TayfurYldz)**, [PR #265](https://github.com/OthmanAdi/planning-with-files/pull/265), [PR #266](https://github.com/OthmanAdi/planning-with-files/pull/266), [PR #273](https://github.com/OthmanAdi/planning-with-files/pull/273), [PR #283](https://github.com/OthmanAdi/planning-with-files/pull/283), [PR #284](https://github.com/OthmanAdi/planning-with-files/pull/284), [PR #285](https://github.com/OthmanAdi/planning-with-files/pull/285), [PR #286](https://github.com/OthmanAdi/planning-with-files/pull/286), [PR #287](https://github.com/OthmanAdi/planning-with-files/pull/287), [PR #295](https://github.com/OthmanAdi/planning-with-files/pull/295), [PR #297](https://github.com/OthmanAdi/planning-with-files/pull/297)
- Bound `PWF_PLAN_ROOT` to the current project around the attestation call in both initializers so an inherited pin cannot redirect attestation, with shell and PowerShell regressions.
- Made the shell slugifier single-line so a plan name with an embedded newline no longer creates an unusable plan directory.
- Guarded OpenCode replay against null and array rows, non-string tool names, and object-valued text while retaining healthy session rows.
- Replaced OpenCode, DSH and Hermes active pointers without following hard links, rejected read-only PowerShell pointers before plan creation, recovered bracketed Windows PowerShell working directories, and bounded concurrent pointer replacement retries. The `ReplaceFile` artifact case left open in #254 was closed by PR #290 in v3.20.8.
- Added a bounded retry for the PowerShell active-pointer pre-check race during concurrent named-plan initialization, preserving root and pointer validation.
- Added explicit root and named attestation targets so a roadmap can be attested without changing the active ticket plan, with selection and failure-path regressions.
- **[@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), [PR #248](https://github.com/OthmanAdi/planning-with-files/pull/248), [PR #249](https://github.com/OthmanAdi/planning-with-files/pull/249), [PR #251](https://github.com/OthmanAdi/planning-with-files/pull/251), [PR #267](https://github.com/OthmanAdi/planning-with-files/pull/267), [PR #277](https://github.com/OthmanAdi/planning-with-files/pull/277), [PR #279](https://github.com/OthmanAdi/planning-with-files/pull/279)
- **[@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), [PR #248](https://github.com/OthmanAdi/planning-with-files/pull/248), [PR #249](https://github.com/OthmanAdi/planning-with-files/pull/249), [PR #251](https://github.com/OthmanAdi/planning-with-files/pull/251), [PR #267](https://github.com/OthmanAdi/planning-with-files/pull/267), [PR #277](https://github.com/OthmanAdi/planning-with-files/pull/277), [PR #279](https://github.com/OthmanAdi/planning-with-files/pull/279), [PR #282](https://github.com/OthmanAdi/planning-with-files/pull/282), [PR #290](https://github.com/OthmanAdi/planning-with-files/pull/290)
- 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.
- Bounded session catchup to exact planning filenames across the canonical scanners and the Hermes, MastraCode, and OpenCode adapters, with lookalike regression coverage.
@@ -129,6 +139,8 @@ These amazing people have contributed code, documentation, or significant improv
- Routed Cursor's native PowerShell hooks through the shared plan resolver so named plans are injected on Windows, and made the resolver's `PWF_PLAN_ROOT` check run on Windows PowerShell 5.1.
- Ported the several-plans rule into the Hermes plugin so two or more named plans without `PLAN_ID` get the selector notice instead of a pointer or timestamp guess.
- Made shell and PowerShell initialization report attestation failures with a recovery command, and added the missing analytics Next Step section across template copies with regression coverage.
- Made phase-status lock ownership exclusive even when a Windows-native `mkdir` reports simultaneous success to two writers.
- Gave PowerShell active-pointer replacement a caller-owned `ReplaceFile` backup that restores the previous pointer after a failed final replacement and removes only its own backup, so no unowned `~RF*.TMP` file is left behind, with a deterministic failure-path regression.
- **[@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.
@@ -213,7 +225,7 @@ These amazing people have contributed code, documentation, or significant improv
- Added `.codex/hooks/pre-compact.sh` (POSIX sh, reuses `resolve-plan-dir.sh`, emits the same flush reminder and `Plan-SHA256` line as the canonical hook), wired it into `.codex/hooks.json`, corrected the `docs/codex.md` hook table, and added two targeted tests
- **Impact:** Codex users on the native `hooks.json` route now get the pre-compaction progress-flush reminder. The hook stays dormant on runtimes that never fire a PreCompact event, with `|| true` wiring that cannot break a session
- **[@Fat-Jan](https://github.com/Fat-Jan)** (Alonso) - [PR #184](https://github.com/OthmanAdi/planning-with-files/pull/184)
- **[@Fat-Jan](https://github.com/Alonso-li)** (Alonso, now @Alonso-li) - [PR #184](https://github.com/OthmanAdi/planning-with-files/pull/184)
- Updated the `docs/codex.md` verification block to grep `^(hooks|codex_hooks)\s` instead of the bare `^codex_hooks\s`, and aligned the follow-up troubleshooting sentence with the `hooks = true` guidance stated earlier in the same document
- The bare pattern stopped matching once Codex moved its canonical feature key from `codex_hooks` to `hooks` in 0.129.0 (openai/codex#20522); the alias still resolves in `config.toml`, but `codex features list` prints only the canonical `hooks`, so the verify step was telling correctly configured users to upgrade
- **Impact:** the documented verification command matches on current Codex versions again, and `docs/codex.md` is internally consistent across the six places that reference the feature flag
@@ -453,6 +465,6 @@ If you've contributed and don't see your name here, please open an issue! We wan
---
**Total Contributors:** 65+ and growing!
**Total Contributors:** 79 (78 community contributors plus the maintainer)
*Last updated: 2026-09-21*
*Last updated: 2026-09-27*
+228 -170
View File
@@ -1,5 +1,5 @@
<div align="center">
<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%">
<img src="media/pwf-banner-v5.webp" alt="PWF: Every task. Every step. Three planning files on a brass-inlaid base beneath an engraved arch" width="100%">
</div>
<h1 align="center">
@@ -22,8 +22,11 @@ Persistent file-based planning for AI coding agents and long-running agent tasks
<p align="center">
<a href="https://github.com/OthmanAdi/planning-with-files/stargazers"><img src="https://img.shields.io/github/stars/OthmanAdi/planning-with-files?style=flat&color=yellow" alt="Stars"></a>
<a href="https://github.com/OthmanAdi/planning-with-files/releases"><img src="https://img.shields.io/github/v/release/OthmanAdi/planning-with-files?style=flat&label=release" alt="Latest release"></a>
<a href="https://skills.sh/othmanadi/planning-with-files"><img src="https://skills.sh/b/othmanadi/planning-with-files" alt="skills.sh installs"></a>
<a href="https://skillsplayground.com/skills/othmanadi-planning-with-files-planning-with-files/"><img src="https://skillsplayground.com/badges/installs/othmanadi-planning-with-files-planning-with-files.svg" alt="Skills Playground installs"></a>
<a href="https://skill-history.com/othmanadi/planning-with-files"><img src="https://skill-history.com/badge/othmanadi/planning-with-files.svg" alt="Downloads"></a>
<a href="https://github.com/OthmanAdi/planning-with-files/issues?q=is%3Aissue+is%3Aclosed"><img src="https://img.shields.io/github/issues-closed/OthmanAdi/planning-with-files?color=success" alt="Closed issues"></a>
<a href="https://github.com/OthmanAdi/planning-with-files/pulls?q=is%3Apr+is%3Aclosed"><img src="https://img.shields.io/github/issues-pr-closed/OthmanAdi/planning-with-files?color=success" alt="Closed PRs"></a>
</p>
<p align="center">
@@ -45,33 +48,24 @@ Persistent file-based planning for AI coding agents and long-running agent tasks
<sub>Proof, comparisons and the repository reference are <a href="#reference">further down</a> · <a href="docs/installation.md">Full install guide</a></sub>
</p>
## Quick Install
```bash
npx skills add OthmanAdi/planning-with-files --skill planning-with-files -g
```
All install methods: [docs/installation.md](docs/installation.md).
---
## Before and after /clear
Every coding agent loses its working memory when the context window resets. The plan does not have to die with it.
### Without planning files
<img src="media/terminal-without-plan.svg" alt="Terminal after /clear without planning files: the user types continue, the agent replies that it has no context from an earlier session and asks the user to describe the task and where they left off" width="560">
The agent re-reads the repo, asks you to restate the goal, and rediscovers work it already finished.
---
### With planning-with-files
<img src="media/terminal-with-plan.svg" alt="Terminal after /clear with planning-with-files: the hook injects a plan data block showing Phase 2 complete and Phase 3 in progress, and the agent resumes Phase 3 by adding the expiry edge-case tests" width="560">
The transcript is illustrative; the `===BEGIN PLAN DATA===` block is the skill's real injection format, written into context by the `UserPromptSubmit` hook from `task_plan.md` on disk. In the project's internal recovery benchmark, a fresh session with the files on disk resumed in 5.0 turns on average against 13.3 for a raw agent (internal v1, author-run; method and limits in [docs/evals.md](docs/evals.md)). That benchmark used the earlier default transcript-catchup behavior. Current automatic recovery uses project files only, so the figure is historical evidence rather than a fresh measurement of the current default.
| At a glance | |
|---|---:|
| Plan files | **3** |
| Agents covered | **60+** |
| Pass rate (with skill) | **96.7%** |
| Test suite | **1084 tests** |
| Survives `/clear` | **yes** |
<p align="center">
<a href="media/pwf-context-story-v1.mp4"><img src="media/pwf-context-story-v1.gif" alt="Illustrated workflow: planning files preserve the task state through a context reset" width="760"></a>
</p>
## Built for long-running agent tasks
@@ -80,23 +74,8 @@ The transcript is illustrative; the `===BEGIN PLAN DATA===` block is the skill's
>
> That is the difference between an agent that forgets after `/clear`, compaction or a crash and one that resumes at the current phase. In the project's own measurements the plan on disk turned a 13.3-turn re-orientation into 5.0 turns, and the skill won 3 of 3 blind A/B comparisons ([numbers and limits](#benchmark-results)). Every mechanism below is a file on disk plus a hook, so it works the same on hour ten as on turn one.
| What breaks long agent runs | What the skill does about it |
|---|---|
| The context window is wiped by `/clear`, compaction, or a crash | The plan is re-read from disk on the next turn; `SessionStart`, `UserPromptSubmit` and `PreCompact` hooks carry the current phase back in |
| Goal drift after 50+ tool calls | The plan head is re-injected every turn; `PWF_INJECT=smart` keeps the goal, the next step and the active phase in the window late in a long plan |
| The agent declares "done" early | Gated mode: the Stop gate holds the stop only while an `in_progress` phase remains, with a block cap and stall detection so an incomplete plan alone never traps a session |
| The plan is silently rewritten by a tool result, a collaborator, or a bug | SHA-256 attestation: a plan body that no longer matches the approved hash is refused at injection with `[PLAN TAMPERED]` |
| Two sessions overwrite each other's phases | The parallel-write guard reports when checked items or completed phases go down between turns |
| Autonomous loops burn tokens on recitation | Autonomous mode drops the per-tool-call recitation and replaces the raw progress tail with a fixed-shape ledger summary; injection is KV-cache stable and one hook fire costs about 289 ms |
| Hooks that quietly stop firing | `/plan-doctor` self-checks resolution, injection, attestation, install surfaces and per-fire latency |
Everything in that table is opt-in per plan and byte-identical to the previous behavior when no mode marker is set. Details: [v3 Long-Running Agent Features](#v3-long-running-agent-features) and [docs/long-running-agent-tasks.md](docs/long-running-agent-tasks.md).
<a id="the-problem"></a>
<a id="the-solution-3-file-pattern"></a>
<a id="the-core-principle"></a>
<a id="why-this-skill"></a>
<a id="the-manus-principles"></a>
<div><a id="the-problem"></a><a id="the-solution-3-file-pattern"></a><a id="the-core-principle"></a><a id="why-this-skill"></a><a id="the-manus-principles"></a></div>
## The 3-file pattern
@@ -107,7 +86,7 @@ Filesystem = Disk (persistent, unlimited)
→ Anything important gets written to disk.
```
For every complex task the skill keeps exactly this in your project, and nothing else:
The skill keeps your plan, findings, and progress in your project:
```
your-project/
@@ -123,85 +102,14 @@ The pattern is the one Manus described before [Meta acquired it for $2 billion o
> "Markdown is my 'working memory' on disk. Since I process information iteratively and my active context has limits, Markdown files serve as scratch pads for notes, checkpoints for progress, building blocks for final deliverables."
> — Manus AI
| Principle | Implementation |
|-----------|----------------|
| Filesystem as memory | Store in files, not context |
| Plan recitation | Re-read plan before decisions (hooks) |
| Error persistence | Log failures in plan file |
| Goal tracking | Checkboxes show progress |
| Completion verification | Stop hook checks all phases |
## Quick Install
<a id="hermes-agent-first-class-support-cli-and-desktop"></a>
**Claude Code, plugin route** (ships everything: skill, hooks, slash commands):
## First-class hosts: native plugins
```
/plugin marketplace add OthmanAdi/planning-with-files
/plugin install planning-with-files@planning-with-files
```
> [!TIP]
> **On these hosts planning-with-files runs as a native plugin: per-turn plan injection, progress reminders, the completion gate, `/pwf` commands and model-callable tools, with no shell hooks to register.** Every other platform gets the skill through the Agent Skills standard and, where the host supports it, the frontmatter or config-file hooks listed in the [platform setup guides](#enhanced-support-per-ide-setup-guides).
**Every other agent**, one line, 60+ agents via the [Agent Skills](https://agentskills.io) standard:
```bash
npx skills add OthmanAdi/planning-with-files --skill planning-with-files -g
```
**npm**, to pin an exact version into a project or vendor it:
```bash
npm install planning-with-files
```
The package carries `SKILL.md`, `scripts/` and `templates/`, so this is the route for locking a version into a repo's dependencies or copying the skill in yourself. It does not register hooks on its own.
**Pi Coding Agent**, same npm package, wired up for you (skill, extension, status bar):
```bash
pi install npm:planning-with-files
```
**Hermes Agent** (Nous Research), native plugin plus skill bundle, CLI and Desktop:
```bash
hermes skills install OthmanAdi/planning-with-files/.hermes/skills/planning-with-files --yes
hermes plugins install OthmanAdi/planning-with-files/.hermes/plugins/planning-with-files
hermes plugins enable planning-with-files
```
**OpenCode**, native plugin plus the skill (the `npx skills add` command above lands in `~/.agents/skills/`, which OpenCode reads):
```json
{ "plugin": ["opencode-planning-with-files"] }
```
in `opencode.json` or `~/.config/opencode/opencode.json`; OpenCode installs it on the next start.
**DeepSeek Harness (DSH)**, native plugin plus the skill (the `npx skills add` command above lands in `~/.agents/skills/`, which DSH reads):
```bash
dsh plugin --profile web add dsh-planning-with-files
```
then restart `dsh web`; the same command works for the `headless`, `sdk` and `acp` profiles.
Under a minute. Safe to re-run. Trigger it by typing `/plan` (plugin) or asking the agent to "plan this task"; the skill also self-triggers on multi-step tasks.
What each route actually ships:
| Route | Skill + scripts + templates | Slash commands | Hooks |
|---|---|---|---|
| Claude Code plugin | yes | **yes** | **yes** |
| `npx skills add` | yes | no | frontmatter hooks, see note |
| `npm install` | yes, under `node_modules/` | no | no, copy the skill in yourself |
| `pi install npm:` | yes | **yes**, Pi commands | **yes**, via the Pi extension |
| `hermes plugins install` | yes, with the skill bundle | **yes**, `/pwf`, `/pwf-status` | **yes**, plugin hooks incl. the gate |
| OpenCode `opencode.json` plugin | yes, with the skill | **yes**, `/pwf`, `/pwf-status` (two copied command files) | **yes**, plugin hooks incl. the gate |
| DeepSeek Harness `dsh plugin add` | yes, with the skill | **yes**, `/pwf`, `/pwf-status` | **yes**, plugin hooks incl. the gate |
| ClawHub / manual copy | yes | no | frontmatter hooks, see note |
Skill-route installs can end up silently hook-less (project trust not accepted, or frontmatter hooks not registering on project-level installs). The hooks are the differentiating mechanism, so if they matter to you, use the plugin route, then verify with `/plan-doctor`. Full matrix and the two silent killers: [docs/installation.md](docs/installation.md#what-each-install-route-actually-ships).
Install acting up? Open your agent and say: *"Read docs/installation.md and docs/troubleshooting.md from OthmanAdi/planning-with-files and fix my install."* Then run `/plan-doctor`.
<details>
<summary><strong>🌐 Available in 5 other languages</strong></summary>
@@ -239,24 +147,7 @@ They live under `skills/i18n/`, one directory deeper than the canonical skill. T
</details>
<details>
<summary><strong>Prefer <code>/planning-with-files</code> with no prefix?</strong></summary>
Copy the skill to your local folder:
**macOS/Linux:**
```bash
cp -r ~/.claude/plugins/cache/planning-with-files/planning-with-files/*/skills/planning-with-files ~/.claude/skills/
```
**Windows (PowerShell):**
```powershell
Copy-Item -Recurse -Path "$env:USERPROFILE\.claude\plugins\cache\planning-with-files\planning-with-files\*\skills\planning-with-files" -Destination "$env:USERPROFILE\.claude\skills\"
```
</details>
<details>
<details id="enhanced-support-per-ide-setup-guides">
<summary><strong>Enhanced Support: per-IDE setup guides</strong></summary>
| IDE | Installation Guide | Integration |
@@ -347,6 +238,10 @@ One hook fire measures 289ms wall-clock since the v3.6.0 optimization, down from
| Version | Highlights |
|---------|------------|
| **v3.21.0** | Explicit root and named attestation targets preserve active selection and project containment (#296). PowerShell initialization retries the concurrent pointer pre-check race with bounded, validated attempts (#294). |
| **v3.20.8** | Claude Code sessions without a plan no longer end every reply with a Stop notice (#288). Cursor hooks move to the current schema with `sessionStart` injection (#262), Gemini CLI hooks inject through `BeforeAgent` and `AfterTool` `hookSpecificOutput` (#292), and both adapters' hook scripts are now executable on macOS and Linux; the Cursor stop hook stays silent once every phase is complete. PowerShell pointer replacement recovers or removes only its own `ReplaceFile` backup (#254). |
| **v3.20.7** | Fixes npm capability disclosure metadata and three unavailable contributor portrait endpoints. The npm package continues to ship the canonical skill and full repository README. |
| **v3.20.6** | Phase-status writers claim one lock owner even with Windows-native `mkdir` (#282). OpenCode, DSH and Hermes safely replace linked active pointers (#283, #284). PowerShell named plans reject read-only pointers before creation (#285), work under bracketed paths (#286), and retry transient concurrent pointer writes and inspection races (#287). The remaining `ReplaceFile` artifact case stays open in #254. |
| **v3.20.5** | OpenCode replay tolerates malformed parts (#273). Initialization reports attestation failures accurately (#277), analytics plans include Next Step (#279), and PowerShell denied writes fail without activating an incomplete named plan (#280). |
| **v3.20.4** | PowerShell route on OneDrive: an `.active_plan` pointer carrying the OneDrive Files On-Demand reparse attribute no longer counts as unsafe, so the Cursor hooks, the resolver and `set-active-plan.ps1` work in projects under OneDrive (#275). The session-catchup copy guard checks tracked copies only (#274). |
| **v3.20.3** | A symlinked or junctioned directory under `.planning/` is never a plan on any route: the shell counters skip it (PR #271 by @ShaunLinTW, #270), and the selection paths of the shell family plus the Codex, OpenCode and DSH counters refuse it too, so one real plan next to a linked one no longer becomes an mtime guess. |
@@ -475,7 +370,101 @@ Full list of everyone who made this project better: [CONTRIBUTORS.md](./CONTRIBU
</details>
All install methods: [docs/installation.md](docs/installation.md).
<details id="full-reference">
<summary><strong>📚 Commands, Multi-agent runs, Benchmarks &amp; more</strong> <code>[15 sections]</code></summary>
### Long-run safeguards
| What breaks long agent runs | What the skill does about it |
|---|---|
| The context window is wiped by `/clear`, compaction, or a crash | The plan is re-read from disk on the next turn; `SessionStart`, `UserPromptSubmit` and `PreCompact` hooks carry the current phase back in |
| Goal drift after 50+ tool calls | The plan head is re-injected every turn; `PWF_INJECT=smart` keeps the goal, the next step and the active phase in the window late in a long plan |
| The agent declares "done" early | Gated mode: the Stop gate holds the stop only while an `in_progress` phase remains, with a block cap and stall detection so an incomplete plan alone never traps a session |
| The plan is silently rewritten by a tool result, a collaborator, or a bug | SHA-256 attestation: a plan body that no longer matches the approved hash is refused at injection with `[PLAN TAMPERED]` |
| Two sessions overwrite each other's phases | The parallel-write guard reports when checked items or completed phases go down between turns |
| Autonomous loops burn tokens on recitation | Autonomous mode drops the per-tool-call recitation and replaces the raw progress tail with a fixed-shape ledger summary; injection is KV-cache stable and one hook fire costs about 289 ms |
| Hooks that quietly stop firing | `/plan-doctor` self-checks resolution, injection, attestation, install surfaces and per-fire latency |
Everything in that table is opt-in per plan and byte-identical to the previous behavior when no mode marker is set. Details: [v3 Long-Running Agent Features](#v3-long-running-agent-features) and [docs/long-running-agent-tasks.md](docs/long-running-agent-tasks.md).
### Installation routes
**Claude Code, plugin route** (ships everything: skill, hooks, slash commands):
```
/plugin marketplace add OthmanAdi/planning-with-files
/plugin install planning-with-files@planning-with-files
```
**Every other agent**, one line, 60+ agents via the [Agent Skills](https://agentskills.io) standard:
```bash
npx skills add OthmanAdi/planning-with-files --skill planning-with-files -g
```
**npm**, to pin an exact version into a project or vendor it:
```bash
npm install planning-with-files
```
The package carries `SKILL.md`, `scripts/` and `templates/`, so this is the route for locking a version into a repo's dependencies or copying the skill in yourself. It does not register hooks on its own.
**Pi Coding Agent**, same npm package, wired up for you (skill, extension, status bar):
```bash
pi install npm:planning-with-files
```
**Hermes Agent** (Nous Research), native plugin plus skill bundle, CLI and Desktop:
```bash
hermes skills install OthmanAdi/planning-with-files/.hermes/skills/planning-with-files --yes
hermes plugins install OthmanAdi/planning-with-files/.hermes/plugins/planning-with-files
hermes plugins enable planning-with-files
```
**OpenCode**, native plugin plus the skill (the `npx skills add` command above lands in `~/.agents/skills/`, which OpenCode reads):
```json
{ "plugin": ["opencode-planning-with-files"] }
```
in `opencode.json` or `~/.config/opencode/opencode.json`; OpenCode installs it on the next start.
**DeepSeek Harness (DSH)**, native plugin plus the skill (the `npx skills add` command above lands in `~/.agents/skills/`, which DSH reads):
```bash
dsh plugin --profile web add dsh-planning-with-files
```
then restart `dsh web`; the same command works for the `headless`, `sdk` and `acp` profiles.
Under a minute. Safe to re-run. Trigger it by typing `/plan` (plugin) or asking the agent to "plan this task"; the skill also self-triggers on multi-step tasks.
What each route actually ships:
| Route | Skill + scripts + templates | Slash commands | Hooks |
|---|---|---|---|
| Claude Code plugin | yes | **yes** | **yes** |
| `npx skills add` | yes | no | frontmatter hooks, see note |
| `npm install` | yes, under `node_modules/` | no | no, copy the skill in yourself |
| `pi install npm:` | yes | **yes**, Pi commands | **yes**, via the Pi extension |
| `hermes plugins install` | yes, with the skill bundle | **yes**, `/pwf`, `/pwf-status` | **yes**, plugin hooks incl. the gate |
| OpenCode `opencode.json` plugin | yes, with the skill | **yes**, `/pwf`, `/pwf-status` (two copied command files) | **yes**, plugin hooks incl. the gate |
| DeepSeek Harness `dsh plugin add` | yes, with the skill | **yes**, `/pwf`, `/pwf-status` | **yes**, plugin hooks incl. the gate |
| ClawHub / manual copy | yes | no | frontmatter hooks, see note |
Skill-route installs can end up silently hook-less (project trust not accepted, or frontmatter hooks not registering on project-level installs). The hooks are the differentiating mechanism, so if they matter to you, use the plugin route, then verify with `/plan-doctor`. Full matrix and the two silent killers: [docs/installation.md](docs/installation.md#what-each-install-route-actually-ships).
Install acting up? Open your agent and say: *"Read docs/installation.md and docs/troubleshooting.md from OthmanAdi/planning-with-files and fix my install."* Then run `/plan-doctor`.
Hermes needs its skill bundle from `.hermes/skills/planning-with-files` (`hermes skills install OthmanAdi/planning-with-files/.hermes/skills/planning-with-files --yes`); OpenCode and DeepSeek Harness read the skill that `npx skills add ... -g` places in `~/.agents/skills/`. Each host's own `/plan` command is never shadowed.
<a id="works-across-18-platforms"></a>
@@ -485,32 +474,16 @@ One skill, three integration tiers. Know what your agent gets before you install
| Tier | Platforms | What you get |
|------|-----------|--------------|
| **Enhanced** (hooks + lifecycle automation) | Claude Code, Cursor, GitHub Copilot, Mastra Code, Gemini CLI, Kiro, Codex, Hermes Agent, CodeBuddy, Factory Droid, OpenCode, DeepSeek Harness | Plan injection every turn, progress reminders, completion check |
| **Enhanced** (hooks + lifecycle automation) | Claude Code, Cursor, GitHub Copilot, Mastra Code, Gemini CLI, Kiro, Codex, Hermes Agent, CodeBuddy, Factory Droid, OpenCode, DeepSeek Harness | Plan injection every turn (Cursor: at session start), progress reminders, completion check |
| **Standard Agent Skills** | Continue, Pi, OpenClaw, Autohand Code, Antigravity, Kilocode, AdaL CLI | SKILL.md discovery via `npx skills add`; the pattern without lifecycle hooks |
| **Agent Skills standard path** (in-tree since v3.7.0) | Zed, Amp, Warp, Devin, Antigravity, Gemini CLI, Cursor | `.agents/skills/planning-with-files/` discovered from a plain `git clone`, no per-tool setup |
Per-platform setup guides, discovery paths and sandbox runtimes are in the collapsible sections under [Quick Install](#quick-install).
Per-platform setup guides, discovery paths and sandbox runtimes are in the collapsible sections below [First-class hosts: native plugins](#first-class-hosts-native-plugins).
<a id="hermes-agent-first-class-support-cli-and-desktop"></a>
## First-class hosts: native plugins
> [!TIP]
> **On these hosts planning-with-files runs as a native plugin: per-turn plan injection, progress reminders, the completion gate, `/pwf` commands and model-callable tools, with no shell hooks to register.** Every other platform gets the skill through the Agent Skills standard and, where the host supports it, the frontmatter or config-file hooks listed in the tables above.
| Host | Install | What runs natively | Guide |
|---|---|---|---|
| Claude Code | `/plugin install planning-with-files@planning-with-files` | 6 lifecycle hooks, 13 slash commands, `/plan-doctor` | [docs/installation.md](docs/installation.md) |
| Pi Coding Agent | `pi install npm:planning-with-files` | 8 Pi events via the bundled extension, `/plan-execute` approval gate, status bar | [docs/pi-agent.md](docs/pi-agent.md) |
| Hermes Agent (CLI and Desktop) | `hermes plugins install OthmanAdi/planning-with-files/.hermes/plugins/planning-with-files` then `hermes plugins enable planning-with-files` | `pre_llm_call` injection, `post_tool_call` reminders, `pre_verify` gate, `/pwf`, tools | [docs/hermes.md](docs/hermes.md) |
| OpenCode | `"plugin": ["opencode-planning-with-files"]` in `opencode.json` | `chat.message` injection, write reminders, compaction flush, `session.idle` gate, `/pwf`, `pwf_*` tools | [docs/opencode.md](docs/opencode.md) |
| DeepSeek Harness | `dsh plugin --profile web add dsh-planning-with-files` | `agent/pre-step` injection, write reminders, post-compaction restore, `agent/turn-stopping` gate, `/pwf`, `pwf_*` tools | [docs/deepseek-harness.md](docs/deepseek-harness.md) |
Hermes needs its skill bundle from `.hermes/skills/planning-with-files` (`hermes skills install OthmanAdi/planning-with-files/.hermes/skills/planning-with-files --yes`); OpenCode and DeepSeek Harness read the skill that `npx skills add ... -g` places in `~/.agents/skills/`. Each host's own `/plan` command is never shadowed.
## Multi-agent runs: orchestrators, workers and subagents
> [!NOTE]
> **NOTE**
> **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.
@@ -530,7 +503,7 @@ To find a saved plan, run the installed `scripts/set-active-plan.sh --list` help
The agent stops at the first rung that applies:
```
1. Task needs 3+ steps or 5+ tool calls? → create the three files first
1. Starting work with planning-with-files? → create the three files first
2. Learned something? → append it to findings.md
3. Did something? → log it in progress.md
4. Phase done? → check it off in task_plan.md
@@ -582,8 +555,8 @@ Slash commands ship with the Claude Code plugin route (see the install matrix ab
Typing `/plan` prefix-matches every `plan*` command in autocomplete; `/planning-with-files:status` autocompletes as `/status` (the older `/plan:status` label predates the rename).
<details>
<summary><strong>Pi, OpenCode, Hermes and DeepSeek Harness commands, and command names per host</strong></summary>
### <strong>Pi, OpenCode, Hermes and DeepSeek Harness commands, and command names per host</strong>
### Pi extension commands
@@ -641,7 +614,7 @@ The DSH plugin registers these in-session commands, typed with no prefix. `/plan
On the plugin route the model-invocable SKILL is `planning-with-files:planning-with-files`; the doubled form is the skill id, not a command you type. The five language variants live under `skills/i18n/`, which the plugin scan does not reach, so there is no `planning-with-files:planning-with-files-de` to invoke by name — reach a translation through its `/plan-ar`, `/plan-de`, `/plan-es`, `/plan-zh` or `/plan-zht` command, or install it as its own skill with `npx skills add OthmanAdi/planning-with-files --skill planning-with-files-de -g`, which registers it under its own name. There is no `/pwf-de` and no `/planning-with-files:planning-with-files-goal`; `/pwf` is just a short alias for `/plan`.
</details>
## v3 Long-Running Agent Features
@@ -699,14 +672,6 @@ Pi runtime modes:
3. **Log ALL Errors** — They help avoid repetition
4. **Never Repeat Failures** — Track attempts, mutate approach
## When to Use
**Use this pattern for:**
- Multi-step tasks (3+ steps)
- Research tasks
- Building/creating projects
- Tasks spanning many tool calls
- Long-running agent sessions that must survive `/clear` and compaction
## Acknowledgments
@@ -724,7 +689,7 @@ Pi runtime modes:
## Reference
Everything below is the proof and the reference half: the benchmarks and their limits, what lands in your project and what the repository ships, and every guide in `docs/`. Release history and community projects sit in the collapsible sections under [Quick Install](#quick-install).
Everything below is the proof and the reference half: the benchmarks and their limits, what lands in your project and what the repository ships, and every guide in `docs/`. Release history and community projects sit in the collapsible sections below [First-class hosts: native plugins](#first-class-hosts-native-plugins).
| | |
|---|---|
@@ -767,8 +732,8 @@ Protocol: the session is hard-stopped at roughly half done, and a fresh session
What the skill writes into your project is three markdown files (see [the 3-file pattern](#the-solution-3-file-pattern)). What the repository ships:
<details>
<summary><strong>Repository layout</strong></summary>
### <strong>Repository layout</strong>
```
planning-with-files/
@@ -790,9 +755,9 @@ planning-with-files/
└── README.md
```
Every release maintains 19 tracked parity targets plus the gitignored ClawHub upload stage when it is present. `scripts/bump-version.py` updates every available target, and CI fails if a tracked variant lags.
Every release maintains 20 tracked parity targets plus the gitignored ClawHub upload stage when it is present. `scripts/bump-version.py` updates every available target, and CI fails if a tracked variant lags. The npm package's `SKILL.md` is byte-identical to the canonical skill.
</details>
## Documentation
@@ -827,6 +792,99 @@ MIT License — feel free to use, modify, and distribute.
**Author:** [Ahmad Othman Ammar Adi](https://github.com/OthmanAdi)
</details>
## Star History
<a href="https://repostars.dev/?repos=OthmanAdi%2Fplanning-with-files&theme=copper"><img src="https://repostars.dev/api/embed?repo=OthmanAdi%2Fplanning-with-files&theme=copper" width="100%" alt="Star History Chart" /></a>
<a href="https://repostars.dev/?repos=OthmanAdi%2Fplanning-with-files&amp;theme=aurora"><img src="https://wsrv.nl/?url=https%3A%2F%2Frepostars.dev%2Fapi%2Fog%3Frepos%3DOthmanAdi%252Fplanning-with-files%26theme%3Daurora&amp;cy=228&amp;ch=334&amp;maxage=1d" width="100%" alt="Star History Chart" /></a>
## ClawHub Downloads
<a href="https://skill-history.com/othmanadi/planning-with-files"><img src="https://skill-history.com/chart/othmanadi/planning-with-files.svg" width="100%" alt="ClawHub Download History Chart" /></a>
## Contributors
Code, documentation, and issue contributors. Select a portrait to open a GitHub profile or archived credit. [Full credits](CONTRIBUTORS.md).
<!-- contributor-portraits:start -->
<p align="center">
<a href="https://github.com/kaichen" title="@kaichen"><img src="https://github.com/kaichen.png?size=56" width="48" height="48" alt="@kaichen"></a>
<a href="https://github.com/fuahyo" title="@fuahyo"><img src="https://github.com/fuahyo.png?size=56" width="48" height="48" alt="@fuahyo"></a>
<a href="https://github.com/lasmarois" title="@lasmarois"><img src="https://github.com/lasmarois.png?size=56" width="48" height="48" alt="@lasmarois"></a>
<a href="https://github.com/aimasteracc" title="@aimasteracc"><img src="https://github.com/aimasteracc.png?size=56" width="48" height="48" alt="@aimasteracc"></a>
<a href="https://github.com/SaladDay" title="@SaladDay"><img src="https://github.com/SaladDay.png?size=56" width="48" height="48" alt="@SaladDay"></a>
<a href="https://github.com/murphyXu" title="@murphyXu"><img src="https://github.com/murphyXu.png?size=56" width="48" height="48" alt="@murphyXu"></a>
<a href="https://github.com/ZWkang" title="@ZWkang"><img src="https://github.com/ZWkang.png?size=56" width="48" height="48" alt="@ZWkang"></a>
<a href="https://github.com/EListenX" title="@EListenX"><img src="https://github.com/EListenX.png?size=56" width="48" height="48" alt="@EListenX"></a>
<a href="https://github.com/lincolnwan" title="@lincolnwan"><img src="https://github.com/lincolnwan.png?size=56" width="48" height="48" alt="@lincolnwan"></a>
<a href="https://github.com/ciberponk" title="@ciberponk"><img src="https://github.com/ciberponk.png?size=56" width="48" height="48" alt="@ciberponk"></a>
<a href="https://github.com/ttttmr" title="@ttttmr"><img src="https://github.com/ttttmr.png?size=56" width="48" height="48" alt="@ttttmr"></a>
<a href="https://github.com/mvanhorn" title="@mvanhorn"><img src="https://github.com/mvanhorn.png?size=56" width="48" height="48" alt="@mvanhorn"></a>
<a href="https://github.com/ebrevdo" title="@ebrevdo"><img src="https://github.com/ebrevdo.png?size=56" width="48" height="48" alt="@ebrevdo"></a>
<a href="https://github.com/bailob" title="@bailob"><img src="https://github.com/bailob.png?size=56" width="48" height="48" alt="@bailob"></a>
<a href="https://github.com/SomSamantray" title="@SomSamantray"><img src="https://github.com/SomSamantray.png?size=56" width="48" height="48" alt="@SomSamantray"></a>
<a href="https://github.com/mmychu" title="@mmychu"><img src="https://github.com/mmychu.png?size=56" width="48" height="48" alt="@mmychu"></a>
<a href="https://github.com/ericshunhinglee-cloud" title="@ericshunhinglee-cloud"><img src="https://github.com/ericshunhinglee-cloud.png?size=56" width="48" height="48" alt="@ericshunhinglee-cloud"></a>
<a href="https://github.com/ShaunLinTW" title="@ShaunLinTW"><img src="https://github.com/ShaunLinTW.png?size=56" width="48" height="48" alt="@ShaunLinTW"></a>
<a href="https://github.com/TayfurYldz" title="@TayfurYldz"><img src="https://github.com/TayfurYldz.png?size=56" width="48" height="48" alt="@TayfurYldz"></a>
<a href="https://github.com/kuei51307-hub" title="@kuei51307-hub"><img src="https://github.com/kuei51307-hub.png?size=56" width="48" height="48" alt="@kuei51307-hub"></a>
<a href="https://github.com/Dphoshoba" title="@Dphoshoba"><img src="https://github.com/Dphoshoba.png?size=56" width="48" height="48" alt="@Dphoshoba"></a>
<a href="https://github.com/sunznx" title="@sunznx"><img src="https://github.com/sunznx.png?size=56" width="48" height="48" alt="@sunznx"></a>
<a href="https://github.com/hzura" title="@hzura"><img src="https://github.com/hzura.png?size=56" width="48" height="48" alt="@hzura"></a>
<a href="https://github.com/wangxiaodong1021" title="@wangxiaodong1021"><img src="https://github.com/wangxiaodong1021.png?size=56" width="48" height="48" alt="@wangxiaodong1021"></a>
<a href="https://github.com/sortakool" title="@sortakool"><img src="https://github.com/sortakool.png?size=56" width="48" height="48" alt="@sortakool"></a>
<a href="https://github.com/lowmiaq-gmail" title="@lowmiaq-gmail"><img src="https://github.com/lowmiaq-gmail.png?size=56" width="48" height="48" alt="@lowmiaq-gmail"></a>
<a href="https://github.com/webwww123" title="@webwww123"><img src="https://github.com/webwww123.png?size=56" width="48" height="48" alt="@webwww123"></a>
<a href="https://github.com/killianMei" title="@killianMei"><img src="https://github.com/killianMei.png?size=56" width="48" height="48" alt="@killianMei"></a>
<a href="https://github.com/GlitterKill" title="@GlitterKill"><img src="https://github.com/GlitterKill.png?size=56" width="48" height="48" alt="@GlitterKill"></a>
<a href="https://github.com/seathatflowsinourveins" title="@seathatflowsinourveins"><img src="https://github.com/seathatflowsinourveins.png?size=56" width="48" height="48" alt="@seathatflowsinourveins"></a>
<a href="https://github.com/fd44fdg" title="@fd44fdg"><img src="https://github.com/fd44fdg.png?size=56" width="48" height="48" alt="@fd44fdg"></a>
<a href="https://github.com/jschmied" title="@jschmied"><img src="https://github.com/jschmied.png?size=56" width="48" height="48" alt="@jschmied"></a>
<a href="https://github.com/yolo0731" title="@yolo0731"><img src="https://github.com/yolo0731.png?size=56" width="48" height="48" alt="@yolo0731"></a>
<a href="https://github.com/ziyu4huang" title="@ziyu4huang"><img src="https://github.com/ziyu4huang.png?size=56" width="48" height="48" alt="@ziyu4huang"></a>
<a href="https://github.com/kcinzgg" title="@kcinzgg"><img src="https://github.com/kcinzgg.png?size=56" width="48" height="48" alt="@kcinzgg"></a>
<a href="https://github.com/mahdiit" title="@mahdiit"><img src="https://github.com/mahdiit.png?size=56" width="48" height="48" alt="@mahdiit"></a>
<a href="https://github.com/Dikshj" title="@Dikshj"><img src="https://github.com/Dikshj.png?size=56" width="48" height="48" alt="@Dikshj"></a>
<a href="https://github.com/2023Anita" title="@2023Anita"><img src="https://github.com/2023Anita.png?size=56" width="48" height="48" alt="@2023Anita"></a>
<a href="https://github.com/GongYuanCaiJi" title="@GongYuanCaiJi"><img src="https://github.com/GongYuanCaiJi.png?size=56" width="48" height="48" alt="@GongYuanCaiJi"></a>
<a href="https://github.com/Alonso-li" title="@Fat-Jan, now @Alonso-li"><img src="https://github.com/Alonso-li.png?size=56" width="48" height="48" alt="@Fat-Jan"></a>
<a href="https://github.com/shunfeng8421" title="@shunfeng8421"><img src="https://github.com/shunfeng8421.png?size=56" width="48" height="48" alt="@shunfeng8421"></a>
<a href="https://github.com/Skulli485" title="@Skulli485"><img src="https://github.com/Skulli485.png?size=56" width="48" height="48" alt="@Skulli485"></a>
<a href="https://github.com/carterusedulm2-maker" title="@carterusedulm2-maker"><img src="https://github.com/carterusedulm2-maker.png?size=56" width="48" height="48" alt="@carterusedulm2-maker"></a>
<a href="https://github.com/gauravvojha" title="@gauravvojha"><img src="https://github.com/gauravvojha.png?size=56" width="48" height="48" alt="@gauravvojha"></a>
<a href="CONTRIBUTORS.md" title="@CleanDev-Fix, archived credit"><img src="https://github.com/identicons/CleanDev-Fix.png" width="48" height="48" alt="@CleanDev-Fix"></a>
<a href="https://github.com/bmyury" title="@bmyury"><img src="https://github.com/bmyury.png?size=56" width="48" height="48" alt="@bmyury"></a>
<a href="https://github.com/oaabahussain" title="@oaabahussain"><img src="https://github.com/oaabahussain.png?size=56" width="48" height="48" alt="@oaabahussain"></a>
<a href="https://github.com/gavinlinasd" title="@gavinlinasd"><img src="https://github.com/gavinlinasd.png?size=56" width="48" height="48" alt="@gavinlinasd"></a>
<a href="https://github.com/xiaolai" title="@xiaolai"><img src="https://github.com/xiaolai.png?size=56" width="48" height="48" alt="@xiaolai"></a>
<a href="https://github.com/githubYiheng" title="@githubYiheng"><img src="https://github.com/githubYiheng.png?size=56" width="48" height="48" alt="@githubYiheng"></a>
<a href="https://github.com/09ashishkapoor" title="@09ashishkapoor"><img src="https://github.com/09ashishkapoor.png?size=56" width="48" height="48" alt="@09ashishkapoor"></a>
<a href="https://github.com/shawnli1874" title="@shawnli1874"><img src="https://github.com/shawnli1874.png?size=56" width="48" height="48" alt="@shawnli1874"></a>
<a href="https://github.com/Leon-Algo" title="@Leon-Algo"><img src="https://github.com/Leon-Algo.png?size=56" width="48" height="48" alt="@Leon-Algo"></a>
<a href="https://github.com/YSAA1" title="@YSAA1"><img src="https://github.com/YSAA1.png?size=56" width="48" height="48" alt="@YSAA1"></a>
<a href="https://github.com/kevinaimonster" title="@kevinaimonster"><img src="https://github.com/kevinaimonster.png?size=56" width="48" height="48" alt="@kevinaimonster"></a>
<a href="https://github.com/wd041216-bit" title="@wd041216-bit"><img src="https://github.com/wd041216-bit.png?size=56" width="48" height="48" alt="@wd041216-bit"></a>
<a href="https://github.com/popey" title="@popey"><img src="https://github.com/popey.png?size=56" width="48" height="48" alt="@popey"></a>
<a href="https://github.com/jonthebeef" title="@jonthebeef"><img src="https://github.com/jonthebeef.png?size=56" width="48" height="48" alt="@jonthebeef"></a>
<a href="https://github.com/codelyc" title="@codelyc"><img src="https://github.com/codelyc.png?size=56" width="48" height="48" alt="@codelyc"></a>
<a href="https://github.com/Guozihong" title="@Guozihong"><img src="https://github.com/Guozihong.png?size=56" width="48" height="48" alt="@Guozihong"></a>
<a href="https://github.com/fahmyelraie" title="@fahmyelraie"><img src="https://github.com/fahmyelraie.png?size=56" width="48" height="48" alt="@fahmyelraie"></a>
<a href="https://github.com/olgasafonova" title="@olgasafonova"><img src="https://github.com/olgasafonova.png?size=56" width="48" height="48" alt="@olgasafonova"></a>
<a href="https://github.com/AZLabsAI" title="@AZLabsAI"><img src="https://github.com/AZLabsAI.png?size=56" width="48" height="48" alt="@AZLabsAI"></a>
<a href="https://github.com/raykuo998" title="@raykuo998"><img src="https://github.com/raykuo998.png?size=56" width="48" height="48" alt="@raykuo998"></a>
<a href="https://github.com/gydx6" title="@gydx6"><img src="https://github.com/gydx6.png?size=56" width="48" height="48" alt="@gydx6"></a>
<a href="https://github.com/waynelee2048" title="@waynelee2048"><img src="https://github.com/waynelee2048.png?size=56" width="48" height="48" alt="@waynelee2048"></a>
<a href="https://github.com/tobrun" title="@tobrun"><img src="https://github.com/tobrun.png?size=56" width="48" height="48" alt="@tobrun"></a>
<a href="https://github.com/markocupic024" title="@markocupic024"><img src="https://github.com/markocupic024.png?size=56" width="48" height="48" alt="@markocupic024"></a>
<a href="https://github.com/tt-a1i" title="@tt-a1i"><img src="https://github.com/tt-a1i.png?size=56" width="48" height="48" alt="@tt-a1i"></a>
<a href="https://github.com/Emin017" title="@Emin017"><img src="https://github.com/Emin017.png?size=56" width="48" height="48" alt="@Emin017"></a>
<a href="https://github.com/TomXPRIME" title="@TomXPRIME"><img src="https://github.com/TomXPRIME.png?size=56" width="48" height="48" alt="@TomXPRIME"></a>
<a href="https://github.com/DLI1996" title="@DLI1996"><img src="https://github.com/DLI1996.png?size=56" width="48" height="48" alt="@DLI1996"></a>
<a href="https://github.com/Stephen-abc" title="@Stephen-abc"><img src="https://github.com/Stephen-abc.png?size=56" width="48" height="48" alt="@Stephen-abc"></a>
<a href="https://github.com/igorcosta" title="@igorcosta"><img src="https://github.com/igorcosta.png?size=56" width="48" height="48" alt="@igorcosta"></a>
<a href="https://github.com/Yigtwxx" title="@Yigtwxx"><img src="https://github.com/Yigtwxx.png?size=56" width="48" height="48" alt="@Yigtwxx"></a>
<a href="https://github.com/RioTheGreat-ai" title="@RioTheGreat-ai"><img src="https://github.com/RioTheGreat-ai.png?size=56" width="48" height="48" alt="@RioTheGreat-ai"></a>
<a href="https://github.com/kmichels" title="@kmichels"><img src="https://github.com/kmichels.png?size=56" width="48" height="48" alt="@kmichels"></a>
<a href="CONTRIBUTORS.md" title="@voidborne-d, archived credit"><img src="https://github.com/identicons/voidborne-d.png" width="48" height="48" alt="@voidborne-d"></a>
</p>
<!-- contributor-portraits:end -->
+11
View File
@@ -37,6 +37,17 @@ file. It does not create the legacy `.plan-attestation` file beside a slug plan.
does not resolve, the helper exits with an error instead of attesting a different
plan through the current-directory fallback.
The shell helper's `--target root` explicitly attests the project's root
`task_plan.md` and writes `.plan-attestation`, even while a named plan is active.
`--target <plan-id>` explicitly attests that named plan. Both forms override
`PLAN_ID` and honor `PWF_PLAN_ROOT`; without a project pin, paths are relative to
the current directory. Neither form changes `.planning/.active_plan`.
Empty, invalid or unresolved targets fail without falling back to another plan.
Named targets retain the shared resolver's containment and linked-directory
checks, and explicit targets reject linked plan files. `--target` is a write-only
form and cannot be combined with `--show` or `--clear`; extra arguments are
rejected. Every attestation write prints its plan and attestation paths first.
The atomic rename is the correctness guarantee. It prevents readers from seeing
a partially written attestation file. The `flock` call is only a cooperative
gate for concurrent writers on systems that provide it.
+23 -10
View File
@@ -29,13 +29,17 @@ Cursor now supports hooks natively via `.cursor/hooks.json`. This skill includes
| Hook | Purpose | Cursor Feature |
|------|---------|----------------|
| `userPromptSubmit` | Injects the selected plan and recent progress | Restores state on every prompt |
| `preToolUse` | Re-reads task_plan.md before tool operations | Keeps goals in context |
| `postToolUse` | Reminds to update plan after file edits | Prevents forgetting updates |
| `sessionStart` | Injects the selected plan and recent progress | Adds plan context when a conversation starts |
| `preToolUse` | Allows tool operations using Cursor's permission schema | Keeps tools available |
| `postToolUse` | Reminds to update plan after file edits through additional context | Prevents forgetting updates |
| `stop` | Checks if all phases are complete | **Auto-continues** if incomplete |
The native PowerShell hooks (`hooks.windows.json`) resolve the same selected
plan directory as the planning scripts, through the shared
The session start hooks return plan content in Cursor's `additional_context`
field. They run when a conversation is created; `beforeSubmitPrompt` cannot
inject context. Cursor Cloud Agents do not run `sessionStart`, so they do not
receive plan context through this hook. The native PowerShell hooks
(`hooks.windows.json`) resolve the
same selected plan directory as the planning scripts, through the shared
`.cursor/skills/planning-with-files/scripts/resolve-plan-dir.ps1`, which must
therefore be present next to the hooks. The rules are the ones every route
applies: an explicit `PLAN_ID` is binding and fails closed when it names no
@@ -74,7 +78,9 @@ your-project/
│ ├── hooks.json ← Hook configuration (bash scripts)
│ ├── hooks.windows.json ← Hook configuration (PowerShell scripts)
│ ├── hooks/
│ │ ├── user-prompt-submit.sh ← Plan context injection (root plan only)
│ │ ├── session-start.sh ← JSON session context response (bash)
│ │ ├── session-start.ps1 ← JSON session context response (PowerShell)
│ │ ├── user-prompt-submit.sh ← Shared bash plan context renderer
│ │ ├── pre-tool-use.sh ← Pre-tool-use script
│ │ ├── post-tool-use.sh ← Post-tool-use script
│ │ ├── stop.sh ← Completion check script
@@ -118,19 +124,26 @@ The `.cursor/hooks.windows.json` file uses PowerShell to execute the `.ps1` hook
## What Each Hook Does
### SessionStart Hook
**Triggers:** When Cursor creates a new conversation
**What it does:** Returns the selected plan and recent progress in
`additional_context`, which Cursor adds to the initial conversation context.
The shell hook uses Python 3 to JSON-escape plan text; the PowerShell hook uses
`ConvertTo-Json`.
### PreToolUse Hook
**Triggers:** Before Write, Edit, Shell, or Read operations
**What it does:** Reads the first 30 lines of `task_plan.md` for context (the bash hook writes them to stderr, the PowerShell hook to the host output). Always returns `{"decision": "allow"}` — it never blocks tools, not even when its helper is missing.
**Claude Code equivalent:** `cat task_plan.md 2>/dev/null | head -30 || true`
**What it does:** Always returns `{"permission":"allow"}`. It does not inject plan context because `preToolUse` only accepts permission responses.
### PostToolUse Hook
**Triggers:** After Write or Edit operations
**What it does:** Outputs a reminder to update `task_plan.md` if a phase was completed.
**What it does:** Returns a reminder to update `progress.md` and `task_plan.md` in `additional_context` when a plan exists.
**Claude Code equivalent:** `echo '[planning-with-files] File updated...'`
+10 -5
View File
@@ -123,9 +123,14 @@ Gemini CLI supports [hooks](https://geminicli.com/docs/hooks/) — lifecycle eve
| Hook Event | What It Does |
|------------|-------------|
| **SessionStart** | Recovers selected context from project planning files without reading agent session stores |
| **BeforeTool** | Reads first 30 lines of `task_plan.md` before write/read/shell operations |
| **BeforeAgent** | Injects the first 30 lines of `task_plan.md` into the user turn through `hookSpecificOutput.additionalContext` |
| **AfterTool** | Reminds to update `progress.md` after file changes |
| **BeforeModel** | Injects current phase awareness before every model call (unique to Gemini!) |
| **SessionEnd** | Reports completion status during shutdown; this is advisory and user-facing only |
Gemini's event-specific output schema matters here: `BeforeAgent` and `AfterTool`
send model context through `hookSpecificOutput.additionalContext`. `BeforeTool`
is reserved for tool validation/argument rewriting, and `BeforeModel` accepts
request/response overrides rather than an `additionalContext` field.
Local agent session history is not part of automatic startup. Explicit `session-catchup.py --metadata <project>` reads same-project local session records and emits aggregate counts only. Use `--replay` for bounded nonce-framed excerpts. The catchup path contains no network request or upload operation.
@@ -159,7 +164,7 @@ cp -r /path/to/planning-with-files/.gemini/hooks ~/.gemini/hooks
2. **Task Detection**: When you describe a complex task, Gemini matches it to the skill
3. **Activation Prompt**: You approve the skill activation
4. **Instructions Loaded**: Full SKILL.md content is added to context
5. **Execution**: Gemini follows the planning workflow with hooks enforcing discipline
5. **Execution**: BeforeAgent re-injects `task_plan.md` context each user turn; AfterTool reminds the agent to record progress after file changes
## Skill Structure
@@ -168,9 +173,9 @@ cp -r /path/to/planning-with-files/.gemini/hooks ~/.gemini/hooks
├── settings.json # Hook configuration (v2.26.0)
├── hooks/ # Hook scripts
│ ├── session-start.sh # Session recovery
│ ├── before-tool.sh # Plan context injection
│ ├── before-agent.sh # Turn-level plan context injection
│ ├── after-tool.sh # Progress update reminder
│ └── before-model.sh # Phase awareness (unique to Gemini)
│ └── session-end.sh # Advisory completion status
└── skills/planning-with-files/
├── SKILL.md # Main skill instructions
├── templates/
Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 MiB

Binary file not shown.
+62 -13
View File
@@ -6,16 +6,19 @@
# attested hash, surfacing a "[PLAN TAMPERED]" warning instead.
#
# Resolution:
# 1. $PLAN_ID env var → ./.planning/$PLAN_ID/
# 2. ./.planning/.active_plan
# 3. Newest ./.planning/<dir>/ by mtime
# 4. Current directory when it is .planning/<valid-slug>/
# 5. Legacy ./task_plan.md at project root
# 1. --target root or <plan-id> → root or named plan under $PWF_PLAN_ROOT (default: cwd)
# 2. $PLAN_ID env var → ./.planning/$PLAN_ID/
# 3. ./.planning/.active_plan
# 4. Newest ./.planning/<dir>/ by mtime
# 5. Current directory when it is .planning/<valid-slug>/
# 6. Legacy ./task_plan.md at project root
#
# Usage:
# sh scripts/attest-plan.sh # attest the active plan
# sh scripts/attest-plan.sh --show # print the stored hash
# sh scripts/attest-plan.sh --clear # remove the attestation (re-open the plan)
# sh scripts/attest-plan.sh --target root # attest the legacy root plan
# sh scripts/attest-plan.sh --target <plan-id> # attest one named plan
set -u
@@ -42,6 +45,39 @@ resolve_from_slug_cwd() {
}
resolve_plan_file() {
if [ -n "${target:-}" ]; then
if [ "${target}" = "root" ]; then
target_root="."
if [ -n "${PWF_PLAN_ROOT:-}" ]; then
# Match the shared resolver's supported absolute local pins.
# A valid project-root junction is intentional, so resolve it.
case "${PWF_PLAN_ROOT}" in
\\\\*|//*|[A-Za-z]:[!\\/]*) return 1 ;;
/*|[A-Za-z]:[\\/]*) ;;
*) return 1 ;;
esac
target_root="$(cd "${PWF_PLAN_ROOT}" 2>/dev/null && pwd -P)" || return 1
fi
[ ! -L "${target_root}/task_plan.md" ] || return 1
[ -f "${target_root}/task_plan.md" ] || return 1
printf "%s\n" "${target_root}/task_plan.md"
return 0
fi
slug_is_valid "${target}" || return 1
# Reuse the binding, project-pin and containment rules. A flag replaces
# PLAN_ID for this call only; it does not change the shared pointer.
[ -f "${RESOLVER}" ] || return 1
target_dir="$(PLAN_ID="${target}" sh "${RESOLVER}" 2>/dev/null)" || return 1
[ -n "${target_dir}" ] || return 1
# Use the shell's physical path spelling: native Windows backslashes
# make sha256sum prefix its output with an escaped-filename marker.
target_dir="$(cd "${target_dir}" 2>/dev/null && pwd -P)" || return 1
[ ! -L "${target_dir}/task_plan.md" ] || return 1
[ -f "${target_dir}/task_plan.md" ] || return 1
printf "%s\n" "${target_dir}/task_plan.md"
return 0
fi
plan_dir=""
if [ -f "${RESOLVER}" ]; then
plan_dir="$(sh "${RESOLVER}" 2>/dev/null)"
@@ -80,7 +116,9 @@ resolve_plan_file() {
attestation_path_for() {
plan_file="$1"
plan_dir="$(dirname "${plan_file}")"
if [ "${plan_dir}" = "." ]; then
if [ "${target:-}" = "root" ]; then
printf "%s\n" "${plan_dir}/.plan-attestation"
elif [ "${plan_dir}" = "." ]; then
# Legacy mode: store at project root.
printf "%s\n" "./.plan-attestation"
else
@@ -101,14 +139,21 @@ compute_hash() {
}
mode="attest"
target=""
usage_error() {
printf "Usage: %s [--show|--clear|--target root|<plan-id>]\n" "$0" >&2
exit 2
}
case "${1:-}" in
--show) mode="show" ;;
--clear) mode="clear" ;;
"") mode="attest" ;;
*)
printf "Usage: %s [--show|--clear]\n" "$0" >&2
exit 2
--show) [ "$#" -eq 1 ] || usage_error; mode="show" ;;
--clear) [ "$#" -eq 1 ] || usage_error; mode="clear" ;;
--target)
[ "$#" -eq 2 ] || usage_error
[ -n "$2" ] || usage_error
target="$2"
;;
"") [ "$#" -eq 0 ] || usage_error ;;
*) usage_error ;;
esac
plan_file="$(resolve_plan_file)" || {
@@ -117,7 +162,9 @@ plan_file="$(resolve_plan_file)" || {
# a mistyped PLAN_ID attested a DIFFERENT plan at rc=0, and an operator
# who now sees a generic not-found is likely to go looking for the wrong
# problem. The selectors are bindings, so say which one refused.
if [ -n "${PLAN_ID:-}" ]; then
if [ -n "${target:-}" ]; then
printf "[plan-attest] --target %s did not resolve to a plan. An explicit target is a binding: nothing was attested and no other plan was substituted.\n" "${target}" >&2
elif [ -n "${PLAN_ID:-}" ]; then
printf "[plan-attest] PLAN_ID=%s names no plan directory under .planning. An explicit selector is a binding: nothing was attested and no other plan was substituted.\n" "${PLAN_ID}" >&2
elif [ -n "${PWF_PLAN_ROOT:-}" ]; then
printf "[plan-attest] PWF_PLAN_ROOT=%s did not resolve to a project root holding a plan. An explicit pin is a binding: nothing was attested and no other plan was substituted.\n" "${PWF_PLAN_ROOT}" >&2
@@ -156,6 +203,8 @@ case "${mode}" in
fi
;;
attest)
printf "Plan: %s\n" "${plan_file}"
printf "Attestation: %s\n" "${attestation_file}"
hash_val="$(compute_hash "${plan_file}")" || exit 1
# v2.40: protect the write with an advisory flock when available so
+5 -5
View File
@@ -11,11 +11,12 @@ Run before tagging a release:
python scripts/bump-version.py 2.37.0
python scripts/bump-version.py 2.37.0 --dry-run
Maintained targets (20 entries: 19 tracked files plus optional local staging):
Maintained targets (21 entries: 20 tracked files plus optional local staging):
skills/planning-with-files/SKILL.md (canonical)
skills/i18n/planning-with-files-{ar,de,es,zh,zht}/SKILL.md
.{codebuddy,codex,cursor,factory,hermes,mastracode,opencode}/skills/planning-with-files/SKILL.md
.agents/skills/planning-with-files/SKILL.md (Agent Skills standard layout)
.pi/skills/planning-with-files/SKILL.md (canonical npm skill)
clawhub-upload/SKILL.md (gitignored; bumped when present)
.claude-plugin/plugin.json
.claude-plugin/marketplace.json
@@ -25,7 +26,6 @@ Maintained targets (20 entries: 19 tracked files plus optional local staging):
Files intentionally left behind (see LAGGING_FILES; do not bump automatically):
.continue and .gemini (intentionally behind),
.pi SKILL.md (no version field; the Pi version lives in its package.json above),
.kiro (-kiro-suffixed scheme)
"""
from __future__ import annotations
@@ -56,12 +56,13 @@ PARITY_FILES = [
(".mastracode/skills/planning-with-files/SKILL.md", "skill_md"),
(".opencode/skills/planning-with-files/SKILL.md", "skill_md"),
(".agents/skills/planning-with-files/SKILL.md", "skill_md"),
(".pi/skills/planning-with-files/SKILL.md", "skill_md"),
("clawhub-upload/SKILL.md", "skill_md"),
(".claude-plugin/plugin.json", "plugin_json"),
(".claude-plugin/marketplace.json", "marketplace_json"),
(".codex-plugin/plugin.json", "plugin_json"),
("CITATION.cff", "citation_cff"),
# npm package for the Pi channel (issue #213: it sat at a third-party 1.1.0
# npm package for the official skill and Pi extension (issue #213: it sat at a third-party 1.1.0
# for 15 releases because nothing bumped it). Same single-"version" JSON
# shape as plugin.json, so the handler is shared. Remember: bumping only
# changes the file; `npm publish` from .pi/skills/planning-with-files/ is a
@@ -72,7 +73,7 @@ PARITY_FILES = [
# Publish staging that remains parity-locked whenever it exists, but is
# intentionally absent from clean clones because the directory is gitignored.
# Keep these paths in PARITY_FILES so maintainer worktrees still bump and
# validate them as part of the canonical 20-entry release set.
# validate them as part of the canonical 21-entry release set.
OPTIONAL_PARITY_FILES = {
"clawhub-upload/SKILL.md": "optional gitignored ClawHub publish staging",
}
@@ -81,7 +82,6 @@ OPTIONAL_PARITY_FILES = {
LAGGING_FILES = [
".continue/skills/planning-with-files/SKILL.md",
".gemini/skills/planning-with-files/SKILL.md",
".pi/skills/planning-with-files/SKILL.md", # no version field; npm version lives in its package.json (parity set)
".kiro/skills/planning-with-files/SKILL.md", # kiro scheme (2.32.0-kiro)
]
+2
View File
@@ -51,6 +51,8 @@ if ($PlanFile -ne "") {
}
if (-not (Test-Path $PlanFile)) {
# Automatic gate checks have nothing to report without a plan; the explicit report keeps its notice.
if ($Gate) { exit 0 }
Write-Host '[planning-with-files] No task_plan.md found -- no active planning session.'
exit 0
}
+3
View File
@@ -79,6 +79,9 @@ else
fi
if [ ! -f "$PLAN_FILE" ]; then
# Automatic gate checks have nothing to report without a plan (header:
# "plans stay silent in --gate mode"); the explicit report keeps its notice.
[ "$GATE" -eq 1 ] && exit 0
echo "[planning-with-files] No task_plan.md found — no active planning session."
exit 0
fi
+53 -6
View File
@@ -18,6 +18,27 @@ param(
[switch]$Gated
)
# Windows PowerShell 5.1 can silently relocate a -File invocation when the
# inherited cwd contains wildcard characters such as [ or ]. Recover the
# physical cwd for a direct invocation before any project-relative paths are
# resolved.
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)) {
Set-Location -LiteralPath ([Environment]::CurrentDirectory)
}
break
}
}
}
$DATE = Get-Date -Format "yyyy-MM-dd"
# Resolve v3 opt-in mode. -Gated implies autonomous and is the stronger marker.
@@ -331,13 +352,39 @@ if ($UsePlanDir) {
# Activate the named plan only after all three planning files are ready.
# This matches init-session.sh and prevents a failed initialization from
# leaving .active_plan pointed at a partial plan directory.
$global:LASTEXITCODE = 0
try {
& $PlanSelector $PlanId *> $null
} catch {
$global:LASTEXITCODE = 1
$pointerSet = $false
for ($attempt = 1; $attempt -le 5; $attempt++) {
$global:LASTEXITCODE = 0
try {
$selectorResult = & $PlanSelector $PlanId 2>&1
} catch {
$selectorResult = $_
$global:LASTEXITCODE = 1
}
if ($LASTEXITCODE -eq 0) {
$pointerSet = $true
break
}
# Another writer can replace the pointer between the selector's
# Get-Item and final-path check. Retry only that transient result.
$transientPointerRace = $selectorResult -is [System.Management.Automation.ErrorRecord] -and
$selectorResult.Exception.Message -in @(
'Error: could not set the active plan pointer: the active plan pointer became unsafe during replacement',
'Error: the active plan pointer must be a regular file within the project.'
)
if ($attempt -eq 5 -or -not $transientPointerRace) {
break
}
Start-Sleep -Milliseconds 50
$global:LASTEXITCODE = 0
try {
& $PlanSelector -VerifyRoot *> $null
} catch {
$global:LASTEXITCODE = 1
}
if ($LASTEXITCODE -ne 0) { break }
}
if ($LASTEXITCODE -ne 0) {
if (-not $pointerSet) {
Write-Error "Error: could not safely update the active plan pointer at $(Join-Path $PlanningRoot '.active_plan')."
exit 1
}

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