Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51c1caa27f | ||
|
|
683d591a79 | ||
|
|
fca9b04f95 | ||
|
|
049943aa08 | ||
|
|
4d24d9a8a2 | ||
|
|
21f25c090f | ||
|
|
b0ee2cdd24 | ||
|
|
4c2b269d30 | ||
|
|
94131052d6 | ||
|
|
a22e4865c6 | ||
|
|
b3ed5256a5 | ||
|
|
605f51e09e | ||
|
|
4c4d0fc74c | ||
|
|
77d56b3d00 | ||
|
|
67bfa9971f | ||
|
|
37d6442d7e |
@@ -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.7"
|
||||
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
|
||||
}
|
||||
|
||||
Regular → Executable
+3
@@ -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
|
||||
|
||||
@@ -368,8 +368,10 @@ if ($UsePlanDir) {
|
||||
# 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 -ceq
|
||||
'Error: could not set the active plan pointer: the active plan pointer became unsafe during replacement'
|
||||
$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
|
||||
}
|
||||
|
||||
@@ -132,6 +132,54 @@ function Test-SafeActiveFile {
|
||||
(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 {
|
||||
@@ -306,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
|
||||
@@ -325,24 +380,40 @@ try {
|
||||
if (-not (Test-SafeActiveFile)) {
|
||||
throw [InvalidOperationException]::new("the active plan pointer became unsafe during replacement")
|
||||
}
|
||||
[IO.File]::Replace($tempFile, $ActiveFile, [NullString]::Value)
|
||||
[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 ""
|
||||
|
||||
@@ -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.7"
|
||||
"version": "3.21.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "planning-with-files",
|
||||
"version": "3.20.7",
|
||||
"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.7"
|
||||
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
|
||||
|
||||
@@ -368,8 +368,10 @@ if ($UsePlanDir) {
|
||||
# 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 -ceq
|
||||
'Error: could not set the active plan pointer: the active plan pointer became unsafe during replacement'
|
||||
$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
|
||||
}
|
||||
|
||||
@@ -132,6 +132,54 @@ function Test-SafeActiveFile {
|
||||
(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 {
|
||||
@@ -306,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
|
||||
@@ -325,24 +380,40 @@ try {
|
||||
if (-not (Test-SafeActiveFile)) {
|
||||
throw [InvalidOperationException]::new("the active plan pointer became unsafe during replacement")
|
||||
}
|
||||
[IO.File]::Replace($tempFile, $ActiveFile, [NullString]::Value)
|
||||
[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,6 +1,6 @@
|
||||
{
|
||||
"name": "planning-with-files",
|
||||
"version": "3.20.7",
|
||||
"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",
|
||||
|
||||
@@ -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.7"
|
||||
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
|
||||
|
||||
@@ -368,8 +368,10 @@ if ($UsePlanDir) {
|
||||
# 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 -ceq
|
||||
'Error: could not set the active plan pointer: the active plan pointer became unsafe during replacement'
|
||||
$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
|
||||
}
|
||||
|
||||
@@ -132,6 +132,54 @@ function Test-SafeActiveFile {
|
||||
(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 {
|
||||
@@ -306,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
|
||||
@@ -325,24 +380,40 @@ try {
|
||||
if (-not (Test-SafeActiveFile)) {
|
||||
throw [InvalidOperationException]::new("the active plan pointer became unsafe during replacement")
|
||||
}
|
||||
[IO.File]::Replace($tempFile, $ActiveFile, [NullString]::Value)
|
||||
[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
|
||||
|
||||
@@ -368,8 +368,10 @@ if ($UsePlanDir) {
|
||||
# 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 -ceq
|
||||
'Error: could not set the active plan pointer: the active plan pointer became unsafe during replacement'
|
||||
$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
|
||||
}
|
||||
|
||||
@@ -132,6 +132,54 @@ function Test-SafeActiveFile {
|
||||
(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 {
|
||||
@@ -306,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
|
||||
@@ -325,24 +380,40 @@ try {
|
||||
if (-not (Test-SafeActiveFile)) {
|
||||
throw [InvalidOperationException]::new("the active plan pointer became unsafe during replacement")
|
||||
}
|
||||
[IO.File]::Replace($tempFile, $ActiveFile, [NullString]::Value)
|
||||
[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
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"version": 1,
|
||||
"hooks": {
|
||||
"userPromptSubmit": [
|
||||
"sessionStart": [
|
||||
{
|
||||
"command": ".cursor/hooks/user-prompt-submit.sh",
|
||||
"command": ".cursor/hooks/session-start.sh",
|
||||
"timeout": 5
|
||||
}
|
||||
],
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 '{}'
|
||||
}
|
||||
Executable
+33
@@ -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
|
||||
@@ -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
@@ -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,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
|
||||
|
||||
Regular → Executable
@@ -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.7"
|
||||
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
|
||||
}
|
||||
|
||||
Regular → Executable
+3
@@ -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
|
||||
|
||||
@@ -368,8 +368,10 @@ if ($UsePlanDir) {
|
||||
# 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 -ceq
|
||||
'Error: could not set the active plan pointer: the active plan pointer became unsafe during replacement'
|
||||
$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
|
||||
}
|
||||
|
||||
@@ -132,6 +132,54 @@ function Test-SafeActiveFile {
|
||||
(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 {
|
||||
@@ -306,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
|
||||
@@ -325,24 +380,40 @@ try {
|
||||
if (-not (Test-SafeActiveFile)) {
|
||||
throw [InvalidOperationException]::new("the active plan pointer became unsafe during replacement")
|
||||
}
|
||||
[IO.File]::Replace($tempFile, $ActiveFile, [NullString]::Value)
|
||||
[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/.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.7"
|
||||
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
|
||||
|
||||
@@ -368,8 +368,10 @@ if ($UsePlanDir) {
|
||||
# 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 -ceq
|
||||
'Error: could not set the active plan pointer: the active plan pointer became unsafe during replacement'
|
||||
$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
|
||||
}
|
||||
|
||||
@@ -132,6 +132,54 @@ function Test-SafeActiveFile {
|
||||
(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 {
|
||||
@@ -306,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
|
||||
@@ -325,24 +380,40 @@ try {
|
||||
if (-not (Test-SafeActiveFile)) {
|
||||
throw [InvalidOperationException]::new("the active plan pointer became unsafe during replacement")
|
||||
}
|
||||
[IO.File]::Replace($tempFile, $ActiveFile, [NullString]::Value)
|
||||
[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
@@ -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
|
||||
|
||||
Regular → Executable
+3
-3
@@ -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
|
||||
@@ -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
Regular → Executable
+4
-18
@@ -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": [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -368,8 +368,10 @@ if ($UsePlanDir) {
|
||||
# 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 -ceq
|
||||
'Error: could not set the active plan pointer: the active plan pointer became unsafe during replacement'
|
||||
$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
|
||||
}
|
||||
|
||||
@@ -132,6 +132,54 @@ function Test-SafeActiveFile {
|
||||
(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 {
|
||||
@@ -306,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
|
||||
@@ -325,24 +380,40 @@ try {
|
||||
if (-not (Test-SafeActiveFile)) {
|
||||
throw [InvalidOperationException]::new("the active plan pointer became unsafe during replacement")
|
||||
}
|
||||
[IO.File]::Replace($tempFile, $ActiveFile, [NullString]::Value)
|
||||
[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,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.7"
|
||||
version: "3.21.0"
|
||||
hermes:
|
||||
tags: [planning, long-running-tasks, context-engineering, workflow]
|
||||
---
|
||||
|
||||
@@ -132,6 +132,54 @@ function Test-SafeActiveFile {
|
||||
(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 {
|
||||
@@ -306,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
|
||||
@@ -325,24 +380,40 @@ try {
|
||||
if (-not (Test-SafeActiveFile)) {
|
||||
throw [InvalidOperationException]::new("the active plan pointer became unsafe during replacement")
|
||||
}
|
||||
[IO.File]::Replace($tempFile, $ActiveFile, [NullString]::Value)
|
||||
[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.7"
|
||||
version: "3.21.0"
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -132,6 +132,54 @@ function Test-SafeActiveFile {
|
||||
(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 {
|
||||
@@ -306,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
|
||||
@@ -325,24 +380,40 @@ try {
|
||||
if (-not (Test-SafeActiveFile)) {
|
||||
throw [InvalidOperationException]::new("the active plan pointer became unsafe during replacement")
|
||||
}
|
||||
[IO.File]::Replace($tempFile, $ActiveFile, [NullString]::Value)
|
||||
[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/.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.7"
|
||||
version: "3.21.0"
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -132,6 +132,54 @@ function Test-SafeActiveFile {
|
||||
(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 {
|
||||
@@ -306,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
|
||||
@@ -325,24 +380,40 @@ try {
|
||||
if (-not (Test-SafeActiveFile)) {
|
||||
throw [InvalidOperationException]::new("the active plan pointer became unsafe during replacement")
|
||||
}
|
||||
[IO.File]::Replace($tempFile, $ActiveFile, [NullString]::Value)
|
||||
[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,5 +1,5 @@
|
||||
<div align="center">
|
||||
<img src="https://raw.githubusercontent.com/OthmanAdi/planning-with-files/v3.20.7/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%">
|
||||
<img src="https://raw.githubusercontent.com/OthmanAdi/planning-with-files/v3.21.0/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">
|
||||
@@ -30,9 +30,9 @@ Persistent file-based planning for AI coding agents and long-running agent tasks
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/OthmanAdi/planning-with-files/blob/v3.20.7/docs/evals.md"><img src="https://img.shields.io/badge/benchmark-96.7%25_pass_(29%2F30)-2da44e?style=flat" alt="Benchmark: 96.7 percent assertion pass rate with skill"></a>
|
||||
<a href="https://github.com/OthmanAdi/planning-with-files/blob/v3.20.7/docs/evals.md"><img src="https://img.shields.io/badge/blind_A%2FB-3%2F3_wins-2da44e?style=flat" alt="Blind A/B: 3 of 3 wins"></a>
|
||||
<a href="https://github.com/OthmanAdi/planning-with-files/blob/v3.20.7/LICENSE"><img src="https://img.shields.io/github/license/OthmanAdi/planning-with-files?style=flat" alt="MIT license"></a>
|
||||
<a href="https://github.com/OthmanAdi/planning-with-files/blob/v3.21.0/docs/evals.md"><img src="https://img.shields.io/badge/benchmark-96.7%25_pass_(29%2F30)-2da44e?style=flat" alt="Benchmark: 96.7 percent assertion pass rate with skill"></a>
|
||||
<a href="https://github.com/OthmanAdi/planning-with-files/blob/v3.21.0/docs/evals.md"><img src="https://img.shields.io/badge/blind_A%2FB-3%2F3_wins-2da44e?style=flat" alt="Blind A/B: 3 of 3 wins"></a>
|
||||
<a href="https://github.com/OthmanAdi/planning-with-files/blob/v3.21.0/LICENSE"><img src="https://img.shields.io/github/license/OthmanAdi/planning-with-files?style=flat" alt="MIT license"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -45,7 +45,7 @@ Persistent file-based planning for AI coding agents and long-running agent tasks
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<sub>Proof, comparisons and the repository reference are <a href="#reference">further down</a> · <a href="https://github.com/OthmanAdi/planning-with-files/blob/v3.20.7/docs/installation.md">Full install guide</a></sub>
|
||||
<sub>Proof, comparisons and the repository reference are <a href="#reference">further down</a> · <a href="https://github.com/OthmanAdi/planning-with-files/blob/v3.21.0/docs/installation.md">Full install guide</a></sub>
|
||||
</p>
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ Persistent file-based planning for AI coding agents and long-running agent tasks
|
||||
npx skills add OthmanAdi/planning-with-files --skill planning-with-files -g
|
||||
```
|
||||
|
||||
All install methods: [docs/installation.md](https://github.com/OthmanAdi/planning-with-files/blob/v3.20.7/docs/installation.md).
|
||||
All install methods: [docs/installation.md](https://github.com/OthmanAdi/planning-with-files/blob/v3.21.0/docs/installation.md).
|
||||
|
||||
---
|
||||
|
||||
@@ -64,7 +64,7 @@ All install methods: [docs/installation.md](https://github.com/OthmanAdi/plannin
|
||||
Every coding agent loses its working memory when the context window resets. The plan does not have to die with it.
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/OthmanAdi/planning-with-files/blob/v3.20.7/media/pwf-context-story-v1.mp4"><img src="https://raw.githubusercontent.com/OthmanAdi/planning-with-files/v3.20.7/media/pwf-context-story-v1.gif" alt="Illustrated workflow: planning files preserve the task state through a context reset" width="760"></a>
|
||||
<a href="https://github.com/OthmanAdi/planning-with-files/blob/v3.21.0/media/pwf-context-story-v1.mp4"><img src="https://raw.githubusercontent.com/OthmanAdi/planning-with-files/v3.21.0/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
|
||||
@@ -141,7 +141,7 @@ npx skills add OthmanAdi/planning-with-files --skill planning-with-files-zht -g
|
||||
|
||||
These are real translations, not an English body with a translated description: the SKILL.md prose, the templates, and the user-facing output of `check-complete`, `init-session` and `session-catchup` are all localized. The status tokens stay literal English (`**Status:** complete`) on purpose, because `check-complete.sh` matches them with `grep -F`, so translating them would disable the completion gate.
|
||||
|
||||
Since v3.10.0 the variants also ship the full script surface: attestation, the Stop gate, the ledger, phase status and plan-doctor used to be canonical-only, which quietly made every non-English install a subset install. Full details, including what changed on the plugin route in v3.11.0, are in [docs/languages.md](https://github.com/OthmanAdi/planning-with-files/blob/v3.20.7/docs/languages.md).
|
||||
Since v3.10.0 the variants also ship the full script surface: attestation, the Stop gate, the ledger, phase status and plan-doctor used to be canonical-only, which quietly made every non-English install a subset install. Full details, including what changed on the plugin route in v3.11.0, are in [docs/languages.md](https://github.com/OthmanAdi/planning-with-files/blob/v3.21.0/docs/languages.md).
|
||||
|
||||
They live under `skills/i18n/`, one directory deeper than the canonical skill. The install commands above are unchanged, because `npx skills add` resolves `--skill` by skill name across the whole repository. The Claude Code plugin scan reads `skills/*/SKILL.md` without recursing, so the plugin route registers the canonical skill alone and no longer carries five extra descriptions in every session's system prompt. On that route the `/plan-ar`, `/plan-de`, `/plan-es`, `/plan-zh` and `/plan-zht` commands read the translated skill from disk instead of invoking it by name.
|
||||
|
||||
@@ -152,18 +152,18 @@ They live under `skills/i18n/`, one directory deeper than the canonical skill. T
|
||||
|
||||
| IDE | Installation Guide | Integration |
|
||||
|-----|-------------------|-------------|
|
||||
| Claude Code | [Installation](https://github.com/OthmanAdi/planning-with-files/blob/v3.20.7/docs/installation.md) | Plugin + SKILL.md + Hooks |
|
||||
| Cursor | [Cursor Setup](https://github.com/OthmanAdi/planning-with-files/blob/v3.20.7/docs/cursor.md) | Skills + [hooks.json](https://cursor.com/docs/hooks) |
|
||||
| GitHub Copilot | [Copilot Setup](https://github.com/OthmanAdi/planning-with-files/blob/v3.20.7/docs/copilot.md) | [Hooks](https://docs.github.com/en/copilot/reference/hooks-configuration) (incl. errorOccurred) |
|
||||
| Mastra Code | [Mastra Setup](https://github.com/OthmanAdi/planning-with-files/blob/v3.20.7/docs/mastra.md) | Skills + [Hooks](https://mastra.ai/docs/mastra-code/configuration) |
|
||||
| Gemini CLI | [Gemini Setup](https://github.com/OthmanAdi/planning-with-files/blob/v3.20.7/docs/gemini.md) | Skills + [Hooks](https://geminicli.com/docs/hooks/) |
|
||||
| Kiro | [Kiro Setup](https://github.com/OthmanAdi/planning-with-files/blob/v3.20.7/docs/kiro.md) | [Agent Skills](https://kiro.dev/docs/skills/) |
|
||||
| Codex | [Codex Setup](https://github.com/OthmanAdi/planning-with-files/blob/v3.20.7/docs/codex.md) | [Skills + Hooks](https://developers.openai.com/codex/skills) |
|
||||
| Hermes Agent | [Hermes Setup](https://github.com/OthmanAdi/planning-with-files/blob/v3.20.7/docs/hermes.md) | Skill + native plugin (tools, `/pwf`, `pre_llm_call`, `post_tool_call`, `pre_verify` gate), CLI and Desktop |
|
||||
| CodeBuddy | [CodeBuddy Setup](https://github.com/OthmanAdi/planning-with-files/blob/v3.20.7/docs/codebuddy.md) | [Skills + Hooks](https://www.codebuddy.ai/docs/cli/skills) |
|
||||
| FactoryAI Droid | [Factory Setup](https://github.com/OthmanAdi/planning-with-files/blob/v3.20.7/docs/factory.md) | [Skills + Hooks](https://docs.factory.ai/cli/configuration/skills) |
|
||||
| OpenCode | [OpenCode Setup](https://github.com/OthmanAdi/planning-with-files/blob/v3.20.7/docs/opencode.md) | Native plugin `opencode-planning-with-files` (`chat.message` injection, write reminders, compaction flush, `session.idle` gate, `pwf_*` tools, `/pwf` commands) + skill |
|
||||
| DeepSeek Harness | [DeepSeek Harness Setup](https://github.com/OthmanAdi/planning-with-files/blob/v3.20.7/docs/deepseek-harness.md) | Native plugin `dsh-planning-with-files` (`agent/pre-step` injection, write reminders, post-compaction restore, `agent/turn-stopping` gate, `pwf_*` tools, `/pwf` commands) + skill |
|
||||
| Claude Code | [Installation](https://github.com/OthmanAdi/planning-with-files/blob/v3.21.0/docs/installation.md) | Plugin + SKILL.md + Hooks |
|
||||
| Cursor | [Cursor Setup](https://github.com/OthmanAdi/planning-with-files/blob/v3.21.0/docs/cursor.md) | Skills + [hooks.json](https://cursor.com/docs/hooks) |
|
||||
| GitHub Copilot | [Copilot Setup](https://github.com/OthmanAdi/planning-with-files/blob/v3.21.0/docs/copilot.md) | [Hooks](https://docs.github.com/en/copilot/reference/hooks-configuration) (incl. errorOccurred) |
|
||||
| Mastra Code | [Mastra Setup](https://github.com/OthmanAdi/planning-with-files/blob/v3.21.0/docs/mastra.md) | Skills + [Hooks](https://mastra.ai/docs/mastra-code/configuration) |
|
||||
| Gemini CLI | [Gemini Setup](https://github.com/OthmanAdi/planning-with-files/blob/v3.21.0/docs/gemini.md) | Skills + [Hooks](https://geminicli.com/docs/hooks/) |
|
||||
| Kiro | [Kiro Setup](https://github.com/OthmanAdi/planning-with-files/blob/v3.21.0/docs/kiro.md) | [Agent Skills](https://kiro.dev/docs/skills/) |
|
||||
| Codex | [Codex Setup](https://github.com/OthmanAdi/planning-with-files/blob/v3.21.0/docs/codex.md) | [Skills + Hooks](https://developers.openai.com/codex/skills) |
|
||||
| Hermes Agent | [Hermes Setup](https://github.com/OthmanAdi/planning-with-files/blob/v3.21.0/docs/hermes.md) | Skill + native plugin (tools, `/pwf`, `pre_llm_call`, `post_tool_call`, `pre_verify` gate), CLI and Desktop |
|
||||
| CodeBuddy | [CodeBuddy Setup](https://github.com/OthmanAdi/planning-with-files/blob/v3.21.0/docs/codebuddy.md) | [Skills + Hooks](https://www.codebuddy.ai/docs/cli/skills) |
|
||||
| FactoryAI Droid | [Factory Setup](https://github.com/OthmanAdi/planning-with-files/blob/v3.21.0/docs/factory.md) | [Skills + Hooks](https://docs.factory.ai/cli/configuration/skills) |
|
||||
| OpenCode | [OpenCode Setup](https://github.com/OthmanAdi/planning-with-files/blob/v3.21.0/docs/opencode.md) | Native plugin `opencode-planning-with-files` (`chat.message` injection, write reminders, compaction flush, `session.idle` gate, `pwf_*` tools, `/pwf` commands) + skill |
|
||||
| DeepSeek Harness | [DeepSeek Harness Setup](https://github.com/OthmanAdi/planning-with-files/blob/v3.21.0/docs/deepseek-harness.md) | Native plugin `dsh-planning-with-files` (`agent/pre-step` injection, write reminders, post-compaction restore, `agent/turn-stopping` gate, `pwf_*` tools, `/pwf` commands) + skill |
|
||||
|
||||
</details>
|
||||
|
||||
@@ -172,13 +172,13 @@ They live under `skills/i18n/`, one directory deeper than the canonical skill. T
|
||||
|
||||
| IDE | Installation Guide | Skill Discovery Path |
|
||||
|-----|-------------------|---------------------|
|
||||
| Continue | [Continue Setup](https://github.com/OthmanAdi/planning-with-files/blob/v3.20.7/docs/continue.md) | `.continue/skills/` + [.prompt files](https://docs.continue.dev/customize/deep-dives/prompts) |
|
||||
| Pi Agent | [Pi Agent Setup](https://github.com/OthmanAdi/planning-with-files/blob/v3.20.7/docs/pi-agent.md) | `.pi/skills/` ([npm package](https://www.npmjs.com/package/@mariozechner/pi-coding-agent)) |
|
||||
| OpenClaw | [OpenClaw Setup](https://github.com/OthmanAdi/planning-with-files/blob/v3.20.7/docs/openclaw.md) | `.openclaw/skills/` ([docs](https://docs.openclaw.ai/tools/skills)) |
|
||||
| Autohand Code | [Autohand Code Setup](https://github.com/OthmanAdi/planning-with-files/blob/v3.20.7/docs/autohand.md) | `~/.autohand/skills/` or `.autohand/skills/` |
|
||||
| Antigravity | [Antigravity Setup](https://github.com/OthmanAdi/planning-with-files/blob/v3.20.7/docs/antigravity.md) | `.agent/skills/` ([docs](https://codelabs.developers.google.com/getting-started-with-antigravity-skills)) |
|
||||
| Kilocode | [Kilocode Setup](https://github.com/OthmanAdi/planning-with-files/blob/v3.20.7/docs/kilocode.md) | `.kilocode/skills/` ([docs](https://kilo.ai/docs/agent-behavior/skills)) |
|
||||
| AdaL CLI (Sylph AI) | [AdaL Setup](https://github.com/OthmanAdi/planning-with-files/blob/v3.20.7/docs/adal.md) | `.adal/skills/` ([docs](https://docs.sylph.ai/features/plugins-and-skills)) |
|
||||
| Continue | [Continue Setup](https://github.com/OthmanAdi/planning-with-files/blob/v3.21.0/docs/continue.md) | `.continue/skills/` + [.prompt files](https://docs.continue.dev/customize/deep-dives/prompts) |
|
||||
| Pi Agent | [Pi Agent Setup](https://github.com/OthmanAdi/planning-with-files/blob/v3.21.0/docs/pi-agent.md) | `.pi/skills/` ([npm package](https://www.npmjs.com/package/@mariozechner/pi-coding-agent)) |
|
||||
| OpenClaw | [OpenClaw Setup](https://github.com/OthmanAdi/planning-with-files/blob/v3.21.0/docs/openclaw.md) | `.openclaw/skills/` ([docs](https://docs.openclaw.ai/tools/skills)) |
|
||||
| Autohand Code | [Autohand Code Setup](https://github.com/OthmanAdi/planning-with-files/blob/v3.21.0/docs/autohand.md) | `~/.autohand/skills/` or `.autohand/skills/` |
|
||||
| Antigravity | [Antigravity Setup](https://github.com/OthmanAdi/planning-with-files/blob/v3.21.0/docs/antigravity.md) | `.agent/skills/` ([docs](https://codelabs.developers.google.com/getting-started-with-antigravity-skills)) |
|
||||
| Kilocode | [Kilocode Setup](https://github.com/OthmanAdi/planning-with-files/blob/v3.21.0/docs/kilocode.md) | `.kilocode/skills/` ([docs](https://kilo.ai/docs/agent-behavior/skills)) |
|
||||
| AdaL CLI (Sylph AI) | [AdaL Setup](https://github.com/OthmanAdi/planning-with-files/blob/v3.21.0/docs/adal.md) | `.adal/skills/` ([docs](https://docs.sylph.ai/features/plugins-and-skills)) |
|
||||
|
||||
> **Note:** If your IDE uses the legacy Rules system instead of Skills, see the [`legacy-rules-support`](https://github.com/OthmanAdi/planning-with-files/tree/legacy-rules-support) branch.
|
||||
|
||||
@@ -189,7 +189,7 @@ They live under `skills/i18n/`, one directory deeper than the canonical skill. T
|
||||
|
||||
| Runtime | Status | Guide | Notes |
|
||||
|---------|--------|-------|-------|
|
||||
| BoxLite | ✅ Documented | [BoxLite Setup](https://github.com/OthmanAdi/planning-with-files/blob/v3.20.7/docs/boxlite.md) | Run Claude Code + planning-with-files inside hardware-isolated micro-VMs |
|
||||
| BoxLite | ✅ Documented | [BoxLite Setup](https://github.com/OthmanAdi/planning-with-files/blob/v3.21.0/docs/boxlite.md) | Run Claude Code + planning-with-files inside hardware-isolated micro-VMs |
|
||||
|
||||
> BoxLite is a sandbox runtime, not an IDE. Skills load via [ClaudeBox](https://github.com/boxlite-ai/claudebox), BoxLite's official Claude Code integration layer.
|
||||
|
||||
@@ -223,7 +223,7 @@ They are complementary stages, not alternatives. Plan mode is where you design a
|
||||
|
||||
### What happens to the plan files after a task is complete?
|
||||
|
||||
They are working memory, not a tracked deliverable. `task_plan.md`, `findings.md`, `progress.md`, and the `.planning/` directory are gitignored by default and are not archived automatically: the next task overwrites the root plan, and a slug directory just stops being active. Anything worth keeping should be promoted into code, a commit, or a doc. See [After Completion: What Happens to the Plan Files](https://github.com/OthmanAdi/planning-with-files/blob/v3.20.7/docs/workflow.md#after-completion-what-happens-to-the-plan-files) for the full lifecycle and how to retain a completed plan. This is a deliberate default, not a missing feature; a completion-triggered archive step is a welcome opt-in extension.
|
||||
They are working memory, not a tracked deliverable. `task_plan.md`, `findings.md`, `progress.md`, and the `.planning/` directory are gitignored by default and are not archived automatically: the next task overwrites the root plan, and a slug directory just stops being active. Anything worth keeping should be promoted into code, a commit, or a doc. See [After Completion: What Happens to the Plan Files](https://github.com/OthmanAdi/planning-with-files/blob/v3.21.0/docs/workflow.md#after-completion-what-happens-to-the-plan-files) for the full lifecycle and how to retain a completed plan. This is a deliberate default, not a missing feature; a completion-triggered archive step is a welcome opt-in extension.
|
||||
|
||||
### How fast are the hooks?
|
||||
|
||||
@@ -238,6 +238,8 @@ 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). |
|
||||
@@ -326,7 +328,7 @@ One hook fire measures 289ms wall-clock since the v3.6.0 optimization, down from
|
||||
| **v2.7.0** | Gemini CLI support |
|
||||
| **v2.2.0** | Session recovery, Windows PowerShell, OS-aware hooks |
|
||||
|
||||
[View all releases](https://github.com/OthmanAdi/planning-with-files/releases) · [CHANGELOG](https://github.com/OthmanAdi/planning-with-files/blob/v3.20.7/CHANGELOG.md)
|
||||
[View all releases](https://github.com/OthmanAdi/planning-with-files/releases) · [CHANGELOG](https://github.com/OthmanAdi/planning-with-files/blob/v3.21.0/CHANGELOG.md)
|
||||
|
||||
> Parallel plan isolation (`.planning/YYYY-MM-DD-slug/` directories) and Codex session isolation shipped in v2.36.0. The `experimental/isolated-planning` branch was the earlier prototype; master is now the canonical location.
|
||||
|
||||
@@ -364,7 +366,7 @@ One hook fire measures 289ms wall-clock since the v3.6.0 optimization, down from
|
||||
|
||||
*Built something? [Open an issue](https://github.com/OthmanAdi/planning-with-files/issues) to get listed!*
|
||||
|
||||
Full list of everyone who made this project better: [CONTRIBUTORS.md](https://github.com/OthmanAdi/planning-with-files/blob/v3.20.7/CONTRIBUTORS.md).
|
||||
Full list of everyone who made this project better: [CONTRIBUTORS.md](https://github.com/OthmanAdi/planning-with-files/blob/v3.21.0/CONTRIBUTORS.md).
|
||||
|
||||
</details>
|
||||
|
||||
@@ -384,7 +386,7 @@ Full list of everyone who made this project better: [CONTRIBUTORS.md](https://gi
|
||||
| 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](https://github.com/OthmanAdi/planning-with-files/blob/v3.20.7/docs/long-running-agent-tasks.md).
|
||||
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](https://github.com/OthmanAdi/planning-with-files/blob/v3.21.0/docs/long-running-agent-tasks.md).
|
||||
|
||||
### Installation routes
|
||||
|
||||
@@ -455,7 +457,7 @@ What each route actually ships:
|
||||
| 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](https://github.com/OthmanAdi/planning-with-files/blob/v3.20.7/docs/installation.md#what-each-install-route-actually-ships).
|
||||
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](https://github.com/OthmanAdi/planning-with-files/blob/v3.21.0/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`.
|
||||
|
||||
@@ -472,7 +474,7 @@ 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 |
|
||||
|
||||
@@ -492,7 +494,7 @@ Per-platform setup guides, discovery paths and sandbox runtimes are in the colla
|
||||
- **Stall-aware gate.** The completion gate reads the ledger, not `progress.md` mtime, so a worker that stopped producing events releases the stop instead of looping.
|
||||
- **One plan, many hosts.** Claude Code, Codex, Pi, Hermes and OpenCode read the same files, the same `.attestation` and the same gate counters, so a plan can be handed from one agent to another mid-run.
|
||||
|
||||
The contract and the `.mode` tokens are specified in the skill itself ([SKILL.md, Autonomous and Gated Modes](https://github.com/OthmanAdi/planning-with-files/blob/v3.20.7/skills/planning-with-files/SKILL.md#autonomous-and-gated-modes-v3)) and in [docs/long-running-agent-tasks.md](https://github.com/OthmanAdi/planning-with-files/blob/v3.20.7/docs/long-running-agent-tasks.md).
|
||||
The contract and the `.mode` tokens are specified in the skill itself ([SKILL.md, Autonomous and Gated Modes](https://github.com/OthmanAdi/planning-with-files/blob/v3.21.0/skills/planning-with-files/SKILL.md#autonomous-and-gated-modes-v3)) and in [docs/long-running-agent-tasks.md](https://github.com/OthmanAdi/planning-with-files/blob/v3.21.0/docs/long-running-agent-tasks.md).
|
||||
|
||||
To find a saved plan, run the installed `scripts/set-active-plan.sh --list` helper from your project directory, or `scripts/set-active-plan.ps1 -List` in PowerShell. It lists named plans under that project's `.planning/` directory, with phase counts for the shipped English and translated templates. `[active]` marks the shared default pointer. Listing does not select a plan or attach a session; use the displayed ID as `PLAN_ID` before starting a concurrent task. Kiro's separate `.kiro/plan` layout is not part of this inventory.
|
||||
|
||||
@@ -533,7 +535,7 @@ Optional gated mode can request continuation only on a host that supports the re
|
||||
|
||||
Keep automatic compaction enabled. The `PreCompact` hook flushes the planning reminder before compaction, and the plugin `SessionStart` path restores the active plan for the continuation.
|
||||
|
||||
Maintainer depth (hook architecture, dispatcher layout, parity tooling) lives in [AGENTS.md](https://github.com/OthmanAdi/planning-with-files/blob/v3.20.7/AGENTS.md) and [docs/](https://github.com/OthmanAdi/planning-with-files/tree/v3.20.7/docs/).
|
||||
Maintainer depth (hook architecture, dispatcher layout, parity tooling) lives in [AGENTS.md](https://github.com/OthmanAdi/planning-with-files/blob/v3.21.0/AGENTS.md) and [docs/](https://github.com/OthmanAdi/planning-with-files/tree/v3.21.0/docs/).
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -697,12 +699,12 @@ Everything below is the proof and the reference half: the benchmarks and their l
|
||||
|
||||
## Benchmark Results
|
||||
|
||||
> **Methodology note:** the 96.7% figure comes from the v2.21.0 evaluation run on `claude-sonnet-4-6` (2026-03-06). It measures file-pattern fidelity (does the agent create and maintain the 3-file structure), not goal-drift over long autonomous runs. Newer models and the autonomous-mode work are not yet covered by this number. Full methodology, dataset, and assertion list: [docs/evals.md](https://github.com/OthmanAdi/planning-with-files/blob/v3.20.7/docs/evals.md).
|
||||
> **Methodology note:** the 96.7% figure comes from the v2.21.0 evaluation run on `claude-sonnet-4-6` (2026-03-06). It measures file-pattern fidelity (does the agent create and maintain the 3-file structure), not goal-drift over long autonomous runs. Newer models and the autonomous-mode work are not yet covered by this number. Full methodology, dataset, and assertion list: [docs/evals.md](https://github.com/OthmanAdi/planning-with-files/blob/v3.21.0/docs/evals.md).
|
||||
|
||||
Evaluated with Anthropic's [skill-creator](https://github.com/anthropics/skills/tree/main/skills/skill-creator) framework: skill v2.21.0, model `claude-sonnet-4-6`, 2026-03-06. 10 parallel subagents, 5 task types, 30 objectively verifiable assertions, 3 blind A/B comparisons.
|
||||
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/OthmanAdi/planning-with-files/v3.20.7/media/benchmark-skill-vs-baseline.svg" width="860" alt="Eval results, with skill vs without: assertions passed 29 of 30 vs 2 of 30, 3-file pattern followed 5 of 5 vs 0 of 5, blind A/B wins 3 of 3 vs 0 of 3, average rubric score 10.0 vs 6.8">
|
||||
<img src="https://raw.githubusercontent.com/OthmanAdi/planning-with-files/v3.21.0/media/benchmark-skill-vs-baseline.svg" width="860" alt="Eval results, with skill vs without: assertions passed 29 of 30 vs 2 of 30, 3-file pattern followed 5 of 5 vs 0 of 5, blind A/B wins 3 of 3 vs 0 of 3, average rubric score 10.0 vs 6.8">
|
||||
</p>
|
||||
|
||||
| Test | with_skill | without_skill |
|
||||
@@ -714,17 +716,17 @@ Evaluated with Anthropic's [skill-creator](https://github.com/anthropics/skills/
|
||||
|
||||
### Recovery after a context wipe
|
||||
|
||||
> **Internal benchmark, v1 (2026-07-06).** Author-run against v3.4.0, harness-authored tasks, deterministic grading, no LLM grades anything. Treat it as the project's own measurement, not an independent comparison. Full method, arms, disclosed limits, and grader validation: [docs/evals.md](https://github.com/OthmanAdi/planning-with-files/blob/v3.20.7/docs/evals.md#test-5-competitive-benchmark-v1-seven-planning-methods-head-to-head-2026-07-06-internal).
|
||||
> **Internal benchmark, v1 (2026-07-06).** Author-run against v3.4.0, harness-authored tasks, deterministic grading, no LLM grades anything. Treat it as the project's own measurement, not an independent comparison. Full method, arms, disclosed limits, and grader validation: [docs/evals.md](https://github.com/OthmanAdi/planning-with-files/blob/v3.21.0/docs/evals.md#test-5-competitive-benchmark-v1-seven-planning-methods-head-to-head-2026-07-06-internal).
|
||||
|
||||
Protocol: the session is hard-stopped at roughly half done, and a fresh session is told only "Continue the work in this directory." Every graded run across every arm ended pytest-green (77/77), so the difference is re-orientation cost, not correctness.
|
||||
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/OthmanAdi/planning-with-files/v3.20.7/media/recovery-turns.svg" width="860" alt="Turns to resume after a context wipe, internal benchmark v1: 5.0 with planning-with-files, 13.3 for a raw agent with no planning method">
|
||||
<img src="https://raw.githubusercontent.com/OthmanAdi/planning-with-files/v3.21.0/media/recovery-turns.svg" width="860" alt="Turns to resume after a context wipe, internal benchmark v1: 5.0 with planning-with-files, 13.3 for a raw agent with no planning method">
|
||||
</p>
|
||||
|
||||
**With the planning files on disk, a resume took 5.0 turns on average; a raw agent took 13.3.** Planning-file recovery plus hook injection put phase state in front of the model before its first tool call, and the same run found no correctness penalty anywhere. This internal v1 used the earlier default transcript catchup; current automatic recovery is file-only and has not been re-benchmarked under the same protocol. An animated summary lives at [docs/benchmark/index.html](https://github.com/OthmanAdi/planning-with-files/blob/v3.20.7/docs/benchmark/index.html) ([rendered view](https://htmlpreview.github.io/?https://github.com/OthmanAdi/planning-with-files/blob/master/docs/benchmark/index.html)).
|
||||
**With the planning files on disk, a resume took 5.0 turns on average; a raw agent took 13.3.** Planning-file recovery plus hook injection put phase state in front of the model before its first tool call, and the same run found no correctness penalty anywhere. This internal v1 used the earlier default transcript catchup; current automatic recovery is file-only and has not been re-benchmarked under the same protocol. An animated summary lives at [docs/benchmark/index.html](https://github.com/OthmanAdi/planning-with-files/blob/v3.21.0/docs/benchmark/index.html) ([rendered view](https://htmlpreview.github.io/?https://github.com/OthmanAdi/planning-with-files/blob/master/docs/benchmark/index.html)).
|
||||
|
||||
[Full methodology and results](https://github.com/OthmanAdi/planning-with-files/blob/v3.20.7/docs/evals.md) · [Technical write-up](https://github.com/OthmanAdi/planning-with-files/blob/v3.20.7/docs/article.md)
|
||||
[Full methodology and results](https://github.com/OthmanAdi/planning-with-files/blob/v3.21.0/docs/evals.md) · [Technical write-up](https://github.com/OthmanAdi/planning-with-files/blob/v3.21.0/docs/article.md)
|
||||
|
||||
## File Structure
|
||||
|
||||
@@ -762,25 +764,25 @@ Every release maintains 20 tracked parity targets plus the gitignored ClawHub up
|
||||
|
||||
| Doc | What it covers |
|
||||
|-----|----------------|
|
||||
| [docs/installation.md](https://github.com/OthmanAdi/planning-with-files/blob/v3.20.7/docs/installation.md) | Every install route, the route matrix, the trust prerequisite |
|
||||
| [docs/quickstart.md](https://github.com/OthmanAdi/planning-with-files/blob/v3.20.7/docs/quickstart.md) | Your first planning session in 5 steps |
|
||||
| [docs/workflow.md](https://github.com/OthmanAdi/planning-with-files/blob/v3.20.7/docs/workflow.md) | Day-to-day usage, plan lifecycle, topic handoffs |
|
||||
| [docs/hermes.md](https://github.com/OthmanAdi/planning-with-files/blob/v3.20.7/docs/hermes.md) | Hermes Agent CLI and Desktop: install, `/pwf`, the `pre_verify` gate, Windows, migration from Claude Code |
|
||||
| [docs/opencode.md](https://github.com/OthmanAdi/planning-with-files/blob/v3.20.7/docs/opencode.md) | OpenCode: the native plugin, `/pwf`, the `session.idle` gate, `pwf_*` tools |
|
||||
| [docs/deepseek-harness.md](https://github.com/OthmanAdi/planning-with-files/blob/v3.20.7/docs/deepseek-harness.md) | DeepSeek Harness: the native plugin, profiles, `/pwf`, the turn-boundary gate, what DSH does not expose |
|
||||
| [docs/evals.md](https://github.com/OthmanAdi/planning-with-files/blob/v3.20.7/docs/evals.md) | Full benchmark methodology, raw numbers, disclosed limits |
|
||||
| [docs/troubleshooting.md](https://github.com/OthmanAdi/planning-with-files/blob/v3.20.7/docs/troubleshooting.md) | When hooks are quiet, plus `/plan-doctor` |
|
||||
| [docs/claude-code-lost-context-after-compaction.md](https://github.com/OthmanAdi/planning-with-files/blob/v3.20.7/docs/claude-code-lost-context-after-compaction.md) | Recovering and preventing context loss from compaction |
|
||||
| [docs/agent-forgets-plan-after-clear.md](https://github.com/OthmanAdi/planning-with-files/blob/v3.20.7/docs/agent-forgets-plan-after-clear.md) | The file-based fix when an agent forgets its plan after `/clear` |
|
||||
| [docs/long-running-agent-tasks.md](https://github.com/OthmanAdi/planning-with-files/blob/v3.20.7/docs/long-running-agent-tasks.md) | Keeping a coding agent on track for hours: modes, gate, ledger |
|
||||
| [MIGRATION.md](https://github.com/OthmanAdi/planning-with-files/blob/v3.20.7/MIGRATION.md) | v2 to v3 migration and host capability tiers |
|
||||
| [SECURITY.md](https://github.com/OthmanAdi/planning-with-files/blob/v3.20.7/SECURITY.md) | Vulnerability reporting and hardening history |
|
||||
| [CONTRIBUTING.md](https://github.com/OthmanAdi/planning-with-files/blob/v3.20.7/CONTRIBUTING.md) | How to contribute; authorship is preserved on merge |
|
||||
| Per-platform guides | 19+ setup docs in [docs/](https://github.com/OthmanAdi/planning-with-files/tree/v3.20.7/docs/), linked from the [platform tables](#works-across-19-platforms) |
|
||||
| [docs/installation.md](https://github.com/OthmanAdi/planning-with-files/blob/v3.21.0/docs/installation.md) | Every install route, the route matrix, the trust prerequisite |
|
||||
| [docs/quickstart.md](https://github.com/OthmanAdi/planning-with-files/blob/v3.21.0/docs/quickstart.md) | Your first planning session in 5 steps |
|
||||
| [docs/workflow.md](https://github.com/OthmanAdi/planning-with-files/blob/v3.21.0/docs/workflow.md) | Day-to-day usage, plan lifecycle, topic handoffs |
|
||||
| [docs/hermes.md](https://github.com/OthmanAdi/planning-with-files/blob/v3.21.0/docs/hermes.md) | Hermes Agent CLI and Desktop: install, `/pwf`, the `pre_verify` gate, Windows, migration from Claude Code |
|
||||
| [docs/opencode.md](https://github.com/OthmanAdi/planning-with-files/blob/v3.21.0/docs/opencode.md) | OpenCode: the native plugin, `/pwf`, the `session.idle` gate, `pwf_*` tools |
|
||||
| [docs/deepseek-harness.md](https://github.com/OthmanAdi/planning-with-files/blob/v3.21.0/docs/deepseek-harness.md) | DeepSeek Harness: the native plugin, profiles, `/pwf`, the turn-boundary gate, what DSH does not expose |
|
||||
| [docs/evals.md](https://github.com/OthmanAdi/planning-with-files/blob/v3.21.0/docs/evals.md) | Full benchmark methodology, raw numbers, disclosed limits |
|
||||
| [docs/troubleshooting.md](https://github.com/OthmanAdi/planning-with-files/blob/v3.21.0/docs/troubleshooting.md) | When hooks are quiet, plus `/plan-doctor` |
|
||||
| [docs/claude-code-lost-context-after-compaction.md](https://github.com/OthmanAdi/planning-with-files/blob/v3.21.0/docs/claude-code-lost-context-after-compaction.md) | Recovering and preventing context loss from compaction |
|
||||
| [docs/agent-forgets-plan-after-clear.md](https://github.com/OthmanAdi/planning-with-files/blob/v3.21.0/docs/agent-forgets-plan-after-clear.md) | The file-based fix when an agent forgets its plan after `/clear` |
|
||||
| [docs/long-running-agent-tasks.md](https://github.com/OthmanAdi/planning-with-files/blob/v3.21.0/docs/long-running-agent-tasks.md) | Keeping a coding agent on track for hours: modes, gate, ledger |
|
||||
| [MIGRATION.md](https://github.com/OthmanAdi/planning-with-files/blob/v3.21.0/MIGRATION.md) | v2 to v3 migration and host capability tiers |
|
||||
| [SECURITY.md](https://github.com/OthmanAdi/planning-with-files/blob/v3.21.0/SECURITY.md) | Vulnerability reporting and hardening history |
|
||||
| [CONTRIBUTING.md](https://github.com/OthmanAdi/planning-with-files/blob/v3.21.0/CONTRIBUTING.md) | How to contribute; authorship is preserved on merge |
|
||||
| Per-platform guides | 19+ setup docs in [docs/](https://github.com/OthmanAdi/planning-with-files/tree/v3.21.0/docs/), linked from the [platform tables](#works-across-19-platforms) |
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions welcome. Start with [CONTRIBUTING.md](https://github.com/OthmanAdi/planning-with-files/blob/v3.20.7/CONTRIBUTING.md). Every shipped contribution is credited: commit authorship is preserved on merge, and contributors are listed in [CONTRIBUTORS.md](https://github.com/OthmanAdi/planning-with-files/blob/v3.20.7/CONTRIBUTORS.md), the CHANGELOG Thanks section, and the release notes.
|
||||
Contributions welcome. Start with [CONTRIBUTING.md](https://github.com/OthmanAdi/planning-with-files/blob/v3.21.0/CONTRIBUTING.md). Every shipped contribution is credited: commit authorship is preserved on merge, and contributors are listed in [CONTRIBUTORS.md](https://github.com/OthmanAdi/planning-with-files/blob/v3.21.0/CONTRIBUTORS.md), the CHANGELOG Thanks section, and the release notes.
|
||||
|
||||
## License
|
||||
|
||||
@@ -802,7 +804,7 @@ MIT License — feel free to use, modify, and distribute.
|
||||
|
||||
## Contributors
|
||||
|
||||
Code, documentation, and issue contributors. Select a portrait to open a GitHub profile or archived credit. [Full credits](https://github.com/OthmanAdi/planning-with-files/blob/v3.20.7/CONTRIBUTORS.md).
|
||||
Code, documentation, and issue contributors. Select a portrait to open a GitHub profile or archived credit. [Full credits](https://github.com/OthmanAdi/planning-with-files/blob/v3.21.0/CONTRIBUTORS.md).
|
||||
|
||||
<!-- contributor-portraits:start -->
|
||||
<p align="center">
|
||||
@@ -820,6 +822,8 @@ Code, documentation, and issue contributors. Select a portrait to open a GitHub
|
||||
<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>
|
||||
@@ -848,7 +852,7 @@ Code, documentation, and issue contributors. Select a portrait to open a GitHub
|
||||
<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="https://github.com/OthmanAdi/planning-with-files/blob/v3.20.7/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/OthmanAdi/planning-with-files/blob/v3.21.0/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>
|
||||
@@ -881,6 +885,6 @@ Code, documentation, and issue contributors. Select a portrait to open a GitHub
|
||||
<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="https://github.com/OthmanAdi/planning-with-files/blob/v3.20.7/CONTRIBUTORS.md" title="@voidborne-d, archived credit"><img src="https://github.com/identicons/voidborne-d.png" width="48" height="48" alt="@voidborne-d"></a>
|
||||
<a href="https://github.com/OthmanAdi/planning-with-files/blob/v3.21.0/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 -->
|
||||
|
||||
@@ -28,7 +28,7 @@ hooks:
|
||||
- type: command
|
||||
command: "[ -n \"${CLAUDE_PLUGIN_ROOT:-}\" ] && exit 0; SH=\"${CLAUDE_SKILL_DIR}/scripts/skill-hook.sh\"; [ -f \"$SH\" ] || SH=$(ls \"$HOME/.claude/skills/planning-with-files/scripts/skill-hook.sh\" \"$HOME/.claude/plugins/marketplaces/planning-with-files/scripts/skill-hook.sh\" 2>/dev/null | head -1); [ -n \"$SH\" ] && [ -f \"$SH\" ] && sh \"$SH\" --event=precompact; exit 0"
|
||||
metadata:
|
||||
version: "3.20.7"
|
||||
version: "3.21.0"
|
||||
---
|
||||
|
||||
# Planning with Files
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "planning-with-files",
|
||||
"version": "3.20.7",
|
||||
"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": [
|
||||
"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
|
||||
|
||||
@@ -368,8 +368,10 @@ if ($UsePlanDir) {
|
||||
# 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 -ceq
|
||||
'Error: could not set the active plan pointer: the active plan pointer became unsafe during replacement'
|
||||
$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
|
||||
}
|
||||
|
||||
@@ -132,6 +132,54 @@ function Test-SafeActiveFile {
|
||||
(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 {
|
||||
@@ -306,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
|
||||
@@ -325,24 +380,40 @@ try {
|
||||
if (-not (Test-SafeActiveFile)) {
|
||||
throw [InvalidOperationException]::new("the active plan pointer became unsafe during replacement")
|
||||
}
|
||||
[IO.File]::Replace($tempFile, $ActiveFile, [NullString]::Value)
|
||||
[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 ""
|
||||
|
||||
@@ -4,6 +4,37 @@ 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
|
||||
|
||||
+2
-2
@@ -7,8 +7,8 @@ authors:
|
||||
- family-names: Adi
|
||||
given-names: Ahmad Othman Ammar
|
||||
alias: OthmanAdi
|
||||
version: "3.20.7"
|
||||
date-released: "2026-09-23"
|
||||
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"
|
||||
|
||||
+16
-6
@@ -106,23 +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), [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)
|
||||
- **[@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 remains open in #254.
|
||||
- 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), [PR #282](https://github.com/OthmanAdi/planning-with-files/pull/282)
|
||||
- **[@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.
|
||||
@@ -131,6 +140,7 @@ These amazing people have contributed code, documentation, or significant improv
|
||||
- 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.
|
||||
@@ -455,6 +465,6 @@ If you've contributed and don't see your name here, please open an issue! We wan
|
||||
|
||||
---
|
||||
|
||||
**Total Contributors:** 77 (76 community contributors plus the maintainer)
|
||||
**Total Contributors:** 79 (78 community contributors plus the maintainer)
|
||||
|
||||
*Last updated: 2026-09-23*
|
||||
*Last updated: 2026-09-27*
|
||||
|
||||
@@ -238,6 +238,8 @@ 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). |
|
||||
@@ -472,7 +474,7 @@ 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 |
|
||||
|
||||
@@ -820,6 +822,8 @@ Code, documentation, and issue contributors. Select a portrait to open a GitHub
|
||||
<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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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/
|
||||
|
||||
+62
-13
@@ -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
|
||||
|
||||
@@ -368,8 +368,10 @@ if ($UsePlanDir) {
|
||||
# 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 -ceq
|
||||
'Error: could not set the active plan pointer: the active plan pointer became unsafe during replacement'
|
||||
$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
|
||||
}
|
||||
|
||||
@@ -132,6 +132,54 @@ function Test-SafeActiveFile {
|
||||
(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 {
|
||||
@@ -306,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
|
||||
@@ -325,24 +380,40 @@ try {
|
||||
if (-not (Test-SafeActiveFile)) {
|
||||
throw [InvalidOperationException]::new("the active plan pointer became unsafe during replacement")
|
||||
}
|
||||
[IO.File]::Replace($tempFile, $ActiveFile, [NullString]::Value)
|
||||
[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/.claude/skills/planning-with-files-ar/scripts/skill-hook.sh\" \"$HOME/.claude/skills/planning-with-files/scripts/skill-hook.sh\" \"$HOME/.claude/plugins/marketplaces/planning-with-files/scripts/skill-hook.sh\"; do [ -f \"$c\" ] && { SH=\"$c\"; break; }; done; [ -n \"$SH\" ] && sh \"$SH\" --event=precompact; exit 0"
|
||||
metadata:
|
||||
version: "3.20.7"
|
||||
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
|
||||
|
||||
@@ -11,6 +11,8 @@ param(
|
||||
if ($env:PLANNING_DISABLED -eq '1') { exit 0 }
|
||||
|
||||
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-ar] لم يتم العثور على task_plan.md — لا توجد جلسة تخطيط نشطة.'
|
||||
exit 0
|
||||
}
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
PLAN_FILE="${1:-task_plan.md}"
|
||||
|
||||
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-ar] لم يتم العثور على task_plan.md — لا توجد جلسة تخطيط نشطة."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -132,6 +132,54 @@ function Test-SafeActiveFile {
|
||||
(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 {
|
||||
@@ -306,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
|
||||
@@ -325,24 +380,40 @@ try {
|
||||
if (-not (Test-SafeActiveFile)) {
|
||||
throw [InvalidOperationException]::new("the active plan pointer became unsafe during replacement")
|
||||
}
|
||||
[IO.File]::Replace($tempFile, $ActiveFile, [NullString]::Value)
|
||||
[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/.claude/skills/planning-with-files-de/scripts/skill-hook.sh\" \"$HOME/.claude/skills/planning-with-files/scripts/skill-hook.sh\" \"$HOME/.claude/plugins/marketplaces/planning-with-files/scripts/skill-hook.sh\"; do [ -f \"$c\" ] && { SH=\"$c\"; break; }; done; [ -n \"$SH\" ] && sh \"$SH\" --event=precompact; exit 0"
|
||||
metadata:
|
||||
version: "3.20.7"
|
||||
version: "3.21.0"
|
||||
---
|
||||
|
||||
# Dateiplanungssystem
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -11,6 +11,8 @@ param(
|
||||
if ($env:PLANNING_DISABLED -eq '1') { exit 0 }
|
||||
|
||||
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-de] task_plan.md nicht gefunden — keine aktive Planungssitzung.'
|
||||
exit 0
|
||||
}
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
PLAN_FILE="${1:-task_plan.md}"
|
||||
|
||||
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-de] task_plan.md nicht gefunden — keine aktive Planungssitzung."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -132,6 +132,54 @@ function Test-SafeActiveFile {
|
||||
(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 {
|
||||
@@ -306,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
|
||||
@@ -325,24 +380,40 @@ try {
|
||||
if (-not (Test-SafeActiveFile)) {
|
||||
throw [InvalidOperationException]::new("the active plan pointer became unsafe during replacement")
|
||||
}
|
||||
[IO.File]::Replace($tempFile, $ActiveFile, [NullString]::Value)
|
||||
[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/.claude/skills/planning-with-files-es/scripts/skill-hook.sh\" \"$HOME/.claude/skills/planning-with-files/scripts/skill-hook.sh\" \"$HOME/.claude/plugins/marketplaces/planning-with-files/scripts/skill-hook.sh\"; do [ -f \"$c\" ] && { SH=\"$c\"; break; }; done; [ -n \"$SH\" ] && sh \"$SH\" --event=precompact; exit 0"
|
||||
metadata:
|
||||
version: "3.20.7"
|
||||
version: "3.21.0"
|
||||
---
|
||||
|
||||
# Sistema de Planificación con Archivos
|
||||
|
||||
@@ -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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user