Compare commits

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

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

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

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-25 15:28:43 +02:00
OthmanAdi 3b7690eafd release: v3.20.7 2026-09-23 22:13:44 +02:00
OthmanAdi f473fa8930 fix: disclose Pi package planning capabilities 2026-09-23 22:09:00 +02:00
OthmanAdi 3827d3c018 release: v3.20.6 2026-09-23 21:58:59 +02:00
OthmanAdi b7edae42dc fix: retry transient PowerShell pointer inspection 2026-09-23 21:51:09 +02:00
OthmanAdi 10c64d464e fix: compare canonical active plan pointer roots 2026-09-23 21:16:28 +02:00
TayfurYldz a82c5a4588 fix: keep DSH core in sync 2026-09-23 21:16:28 +02:00
TayfurYldz 16b6a452bc fix: replace OpenCode active pointer safely 2026-09-23 21:16:28 +02:00
TayfurYldz abd39f7e9b fix: retry concurrent PowerShell pointer replacement 2026-09-23 20:55:49 +02:00
TayfurYldz 388b591f77 fix: recover PowerShell bracketed project cwd 2026-09-23 20:55:43 +02:00
TayfurYldz 87b2553fa1 test: scope read-only pointer case to Windows 2026-09-23 20:55:38 +02:00
TayfurYldz a3bd42954b fix: reject read-only active plan pointers 2026-09-23 20:55:38 +02:00
TayfurYldz 5165ffe847 fix: replace Hermes active pointer safely 2026-09-23 20:55:22 +02:00
kuei51307-hub f7b9be9d58 fix(locking): claim phase status lock atomically 2026-09-23 20:54:59 +02:00
OthmanAdi 0827eeaecf docs: add official skills.sh install badge 2026-09-22 22:07:58 +02:00
OthmanAdi 09d2254d76 docs: restore ClawHub chart below star history 2026-09-22 21:58:33 +02:00
OthmanAdi 78e5bf7b5b docs: simplify star history to the graph 2026-09-22 21:57:34 +02:00
OthmanAdi 27ab93c15d docs: show full star history below reference dropdown 2026-09-22 21:49:40 +02:00
OthmanAdi 4a40450390 docs: remove workflow caption 2026-09-22 19:51:19 +02:00
OthmanAdi 0bd06d344b docs: refresh README layout and visual story 2026-09-22 17:38:29 +02:00
OthmanAdi 5ac39bcb41 docs: align v3.20.5 citation date and replay description 2026-09-21 21:00:22 +02:00
OthmanAdi b81c488d46 release: v3.20.5 2026-09-21 20:59:55 +02:00
Shaun Lin f869dfb4d0 fix: fail PowerShell init on denied writes 2026-09-21 20:57:40 +02:00
kuei51307-hub 428b96c982 fix(init): report attestation failures 2026-09-21 20:57:36 +02:00
kuei51307-hub cd0bc3d202 fix(templates): add analytics next step 2026-09-21 20:56:57 +02:00
TayfurYldz 5ad5452276 fix: tolerate malformed OpenCode replay parts 2026-09-21 20:56:53 +02:00
203 changed files with 8608 additions and 1446 deletions
+1 -1
View File
@@ -28,7 +28,7 @@ hooks:
- type: command
command: "SH=\"${CLAUDE_SKILL_DIR}/scripts/skill-hook.sh\"; [ -f \"$SH\" ] || SH=$(ls \"$HOME/.claude/skills/planning-with-files/scripts/skill-hook.sh\" \"$HOME/.claude/plugins/marketplaces/planning-with-files/scripts/skill-hook.sh\" 2>/dev/null | head -1); [ -n \"$SH\" ] && [ -f \"$SH\" ] && sh \"$SH\" --event=precompact; exit 0"
metadata:
version: "3.20.4"
version: "3.21.0"
---
# Planning with Files
@@ -6,16 +6,19 @@
# attested hash, surfacing a "[PLAN TAMPERED]" warning instead.
#
# Resolution:
# 1. $PLAN_ID env var → ./.planning/$PLAN_ID/
# 2. ./.planning/.active_plan
# 3. Newest ./.planning/<dir>/ by mtime
# 4. Current directory when it is .planning/<valid-slug>/
# 5. Legacy ./task_plan.md at project root
# 1. --target root or <plan-id> → root or named plan under $PWF_PLAN_ROOT (default: cwd)
# 2. $PLAN_ID env var → ./.planning/$PLAN_ID/
# 3. ./.planning/.active_plan
# 4. Newest ./.planning/<dir>/ by mtime
# 5. Current directory when it is .planning/<valid-slug>/
# 6. Legacy ./task_plan.md at project root
#
# Usage:
# sh scripts/attest-plan.sh # attest the active plan
# sh scripts/attest-plan.sh --show # print the stored hash
# sh scripts/attest-plan.sh --clear # remove the attestation (re-open the plan)
# sh scripts/attest-plan.sh --target root # attest the legacy root plan
# sh scripts/attest-plan.sh --target <plan-id> # attest one named plan
set -u
@@ -42,6 +45,39 @@ resolve_from_slug_cwd() {
}
resolve_plan_file() {
if [ -n "${target:-}" ]; then
if [ "${target}" = "root" ]; then
target_root="."
if [ -n "${PWF_PLAN_ROOT:-}" ]; then
# Match the shared resolver's supported absolute local pins.
# A valid project-root junction is intentional, so resolve it.
case "${PWF_PLAN_ROOT}" in
\\\\*|//*|[A-Za-z]:[!\\/]*) return 1 ;;
/*|[A-Za-z]:[\\/]*) ;;
*) return 1 ;;
esac
target_root="$(cd "${PWF_PLAN_ROOT}" 2>/dev/null && pwd -P)" || return 1
fi
[ ! -L "${target_root}/task_plan.md" ] || return 1
[ -f "${target_root}/task_plan.md" ] || return 1
printf "%s\n" "${target_root}/task_plan.md"
return 0
fi
slug_is_valid "${target}" || return 1
# Reuse the binding, project-pin and containment rules. A flag replaces
# PLAN_ID for this call only; it does not change the shared pointer.
[ -f "${RESOLVER}" ] || return 1
target_dir="$(PLAN_ID="${target}" sh "${RESOLVER}" 2>/dev/null)" || return 1
[ -n "${target_dir}" ] || return 1
# Use the shell's physical path spelling: native Windows backslashes
# make sha256sum prefix its output with an escaped-filename marker.
target_dir="$(cd "${target_dir}" 2>/dev/null && pwd -P)" || return 1
[ ! -L "${target_dir}/task_plan.md" ] || return 1
[ -f "${target_dir}/task_plan.md" ] || return 1
printf "%s\n" "${target_dir}/task_plan.md"
return 0
fi
plan_dir=""
if [ -f "${RESOLVER}" ]; then
plan_dir="$(sh "${RESOLVER}" 2>/dev/null)"
@@ -80,7 +116,9 @@ resolve_plan_file() {
attestation_path_for() {
plan_file="$1"
plan_dir="$(dirname "${plan_file}")"
if [ "${plan_dir}" = "." ]; then
if [ "${target:-}" = "root" ]; then
printf "%s\n" "${plan_dir}/.plan-attestation"
elif [ "${plan_dir}" = "." ]; then
# Legacy mode: store at project root.
printf "%s\n" "./.plan-attestation"
else
@@ -101,14 +139,21 @@ compute_hash() {
}
mode="attest"
target=""
usage_error() {
printf "Usage: %s [--show|--clear|--target root|<plan-id>]\n" "$0" >&2
exit 2
}
case "${1:-}" in
--show) mode="show" ;;
--clear) mode="clear" ;;
"") mode="attest" ;;
*)
printf "Usage: %s [--show|--clear]\n" "$0" >&2
exit 2
--show) [ "$#" -eq 1 ] || usage_error; mode="show" ;;
--clear) [ "$#" -eq 1 ] || usage_error; mode="clear" ;;
--target)
[ "$#" -eq 2 ] || usage_error
[ -n "$2" ] || usage_error
target="$2"
;;
"") [ "$#" -eq 0 ] || usage_error ;;
*) usage_error ;;
esac
plan_file="$(resolve_plan_file)" || {
@@ -117,7 +162,9 @@ plan_file="$(resolve_plan_file)" || {
# a mistyped PLAN_ID attested a DIFFERENT plan at rc=0, and an operator
# who now sees a generic not-found is likely to go looking for the wrong
# problem. The selectors are bindings, so say which one refused.
if [ -n "${PLAN_ID:-}" ]; then
if [ -n "${target:-}" ]; then
printf "[plan-attest] --target %s did not resolve to a plan. An explicit target is a binding: nothing was attested and no other plan was substituted.\n" "${target}" >&2
elif [ -n "${PLAN_ID:-}" ]; then
printf "[plan-attest] PLAN_ID=%s names no plan directory under .planning. An explicit selector is a binding: nothing was attested and no other plan was substituted.\n" "${PLAN_ID}" >&2
elif [ -n "${PWF_PLAN_ROOT:-}" ]; then
printf "[plan-attest] PWF_PLAN_ROOT=%s did not resolve to a project root holding a plan. An explicit pin is a binding: nothing was attested and no other plan was substituted.\n" "${PWF_PLAN_ROOT}" >&2
@@ -156,6 +203,8 @@ case "${mode}" in
fi
;;
attest)
printf "Plan: %s\n" "${plan_file}"
printf "Attestation: %s\n" "${attestation_file}"
hash_val="$(compute_hash "${plan_file}")" || exit 1
# v2.40: protect the write with an advisory flock when available so
@@ -51,6 +51,8 @@ if ($PlanFile -ne "") {
}
if (-not (Test-Path $PlanFile)) {
# Automatic gate checks have nothing to report without a plan; the explicit report keeps its notice.
if ($Gate) { exit 0 }
Write-Host '[planning-with-files] No task_plan.md found -- no active planning session.'
exit 0
}
+3
View File
@@ -79,6 +79,9 @@ else
fi
if [ ! -f "$PLAN_FILE" ]; then
# Automatic gate checks have nothing to report without a plan (header:
# "plans stay silent in --gate mode"); the explicit report keeps its notice.
[ "$GATE" -eq 1 ] && exit 0
echo "[planning-with-files] No task_plan.md found — no active planning session."
exit 0
fi
@@ -18,6 +18,27 @@ param(
[switch]$Gated
)
# Windows PowerShell 5.1 can silently relocate a -File invocation when the
# inherited cwd contains wildcard characters such as [ or ]. Recover the
# physical cwd for a direct invocation before any project-relative paths are
# resolved.
if ($PSVersionTable.PSVersion.Major -eq 5 -and
[System.Management.Automation.WildcardPattern]::ContainsWildcardCharacters([Environment]::CurrentDirectory)) {
$processArgs = [Environment]::GetCommandLineArgs()
for ($index = 0; $index -lt ($processArgs.Length - 1); $index++) {
if ($processArgs[$index] -ieq '-File') {
$entryScript = $processArgs[$index + 1]
if (-not [IO.Path]::IsPathRooted($entryScript)) {
$entryScript = Join-Path ([Environment]::CurrentDirectory) $entryScript
}
if ([IO.Path]::GetFullPath($entryScript) -eq [IO.Path]::GetFullPath($PSCommandPath)) {
Set-Location -LiteralPath ([Environment]::CurrentDirectory)
}
break
}
}
}
$DATE = Get-Date -Format "yyyy-MM-dd"
# Resolve v3 opt-in mode. -Gated implies autonomous and is the stronger marker.
@@ -80,6 +101,26 @@ function Get-InheritedMode([string]$CurrentMode) {
return $CurrentMode
}
function Format-AttestationFailureReason {
param([object[]]$Output, [string]$Fallback)
$Reason = @(
$Output |
ForEach-Object {
if ($null -ne $_) { ($_.ToString()).Trim() }
} |
Where-Object { $_ }
) -join " "
$Reason = ($Reason -replace '\s+', ' ').Trim()
if ([string]::IsNullOrWhiteSpace($Reason)) {
return $Fallback
}
if ($Reason.Length -gt 300) {
return ($Reason.Substring(0, 297) + "...")
}
return $Reason
}
# Validate template
if ($Template -ne "default" -and $Template -ne "analytics") {
Write-Host "Unknown template: $Template (available: default, analytics). Using default."
@@ -136,20 +177,7 @@ if ($UsePlanDir) {
$Counter++
}
$TargetDir = Join-Path $PlanningRoot $PlanId
New-Item -ItemType Directory -Path $TargetDir -Force | Out-Null
# Reuse the selector's contained, atomic pointer replacement. Set-Content
# would follow a reparse point and truncate a hardlinked pointer in place,
# overwriting whichever file shares that inode.
$global:LASTEXITCODE = 0
try {
& $PlanSelector $PlanId *> $null
} catch {
$global:LASTEXITCODE = 1
}
if ($LASTEXITCODE -ne 0) {
Write-Error "Error: could not safely update the active plan pointer at $(Join-Path $PlanningRoot '.active_plan')."
exit 1
}
New-Item -ItemType Directory -Path $TargetDir -Force -ErrorAction Stop | Out-Null
$Mode = Get-InheritedMode $Mode
} else {
$TargetDir = (Get-Location).Path
@@ -173,11 +201,12 @@ if ($UsePlanDir) {
Write-Host "PLAN_ID=$PlanId"
}
try {
# Create task_plan.md if it doesn't exist
if (-not (Test-Path -LiteralPath $TaskPlanPath)) {
$AnalyticsPlan = Join-Path $TemplateDir "analytics_task_plan.md"
if ($Template -eq "analytics" -and (Test-Path $AnalyticsPlan)) {
Copy-Item -LiteralPath $AnalyticsPlan -Destination $TaskPlanPath
Copy-Item -LiteralPath $AnalyticsPlan -Destination $TaskPlanPath -ErrorAction Stop
} else {
@"
# Task Plan: [Brief Description]
@@ -226,7 +255,7 @@ Phase 1
## Errors Encountered
| Error | Resolution |
|-------|------------|
"@ | Out-File -LiteralPath $TaskPlanPath -Encoding UTF8
"@ | Out-File -LiteralPath $TaskPlanPath -Encoding UTF8 -ErrorAction Stop
}
Write-Host "Created $TaskPlanDisplay"
} else {
@@ -237,7 +266,7 @@ Phase 1
if (-not (Test-Path -LiteralPath $FindingsPath)) {
$AnalyticsFindings = Join-Path $TemplateDir "analytics_findings.md"
if ($Template -eq "analytics" -and (Test-Path $AnalyticsFindings)) {
Copy-Item -LiteralPath $AnalyticsFindings -Destination $FindingsPath
Copy-Item -LiteralPath $AnalyticsFindings -Destination $FindingsPath -ErrorAction Stop
} else {
@"
# Findings & Decisions
@@ -258,7 +287,7 @@ if (-not (Test-Path -LiteralPath $FindingsPath)) {
## Resources
-
"@ | Out-File -LiteralPath $FindingsPath -Encoding UTF8
"@ | Out-File -LiteralPath $FindingsPath -Encoding UTF8 -ErrorAction Stop
}
Write-Host "Created $FindingsDisplay"
} else {
@@ -287,7 +316,7 @@ if (-not (Test-Path -LiteralPath $ProgressPath)) {
### Errors
| Error | Resolution |
|-------|------------|
"@ | Out-File -LiteralPath $ProgressPath -Encoding UTF8
"@ | Out-File -LiteralPath $ProgressPath -Encoding UTF8 -ErrorAction Stop
} else {
@"
# Progress Log
@@ -308,12 +337,58 @@ if (-not (Test-Path -LiteralPath $ProgressPath)) {
### Errors
| Error | Resolution |
|-------|------------|
"@ | Out-File -LiteralPath $ProgressPath -Encoding UTF8
"@ | Out-File -LiteralPath $ProgressPath -Encoding UTF8 -ErrorAction Stop
}
Write-Host "Created $ProgressDisplay"
} else {
Write-Host "$ProgressDisplay already exists, skipping"
}
} catch {
Write-Error "Error: could not initialize planning files in '$TargetDir': $($_.Exception.Message)"
exit 1
}
if ($UsePlanDir) {
# Activate the named plan only after all three planning files are ready.
# This matches init-session.sh and prevents a failed initialization from
# leaving .active_plan pointed at a partial plan directory.
$pointerSet = $false
for ($attempt = 1; $attempt -le 5; $attempt++) {
$global:LASTEXITCODE = 0
try {
$selectorResult = & $PlanSelector $PlanId 2>&1
} catch {
$selectorResult = $_
$global:LASTEXITCODE = 1
}
if ($LASTEXITCODE -eq 0) {
$pointerSet = $true
break
}
# Another writer can replace the pointer between the selector's
# Get-Item and final-path check. Retry only that transient result.
$transientPointerRace = $selectorResult -is [System.Management.Automation.ErrorRecord] -and
$selectorResult.Exception.Message -in @(
'Error: could not set the active plan pointer: the active plan pointer became unsafe during replacement',
'Error: the active plan pointer must be a regular file within the project.'
)
if ($attempt -eq 5 -or -not $transientPointerRace) {
break
}
Start-Sleep -Milliseconds 50
$global:LASTEXITCODE = 0
try {
& $PlanSelector -VerifyRoot *> $null
} catch {
$global:LASTEXITCODE = 1
}
if ($LASTEXITCODE -ne 0) { break }
}
if (-not $pointerSet) {
Write-Error "Error: could not safely update the active plan pointer at $(Join-Path $PlanningRoot '.active_plan')."
exit 1
}
}
Write-Host ""
Write-Host "Planning files initialized!"
@@ -355,8 +430,11 @@ if ($Mode -ne "") {
# to another project or plan (#261, #237). Root mode clears both instead:
# the attester only falls back to the legacy ./task_plan.md when no
# selector is set, and a bound pin would make it refuse the root plan.
$AttestationSucceeded = $false
$AttestationCommand = "attest-plan"
$AttestationReason = "task_plan.md was not available for attestation"
$PlanFilePwf = Join-Path $PlanDirPwf "task_plan.md"
if (Test-Path -LiteralPath $PlanFilePwf) {
if (Test-Path -LiteralPath $PlanFilePwf -PathType Leaf) {
$HadPlanId = Test-Path Env:PLAN_ID
$PreviousPlanId = $env:PLAN_ID
$HadPlanRoot = Test-Path Env:PWF_PLAN_ROOT
@@ -370,21 +448,54 @@ if ($Mode -ne "") {
Remove-Item Env:PLAN_ID -ErrorAction SilentlyContinue
}
# A called script can return without changing $LASTEXITCODE, so
# clear the inherited value before every attempt. Both a non-zero
# status and a terminating exception mean the plan is not attested.
$global:LASTEXITCODE = 0
$AttestOutput = @()
$IsWindowsHost = [Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT
if ($IsWindowsHost) {
$AttestPs1 = Join-Path $ScriptDir "attest-plan.ps1"
if (Test-Path -LiteralPath $AttestPs1) {
& $AttestPs1 *> $null
$AttestationCommand = "attest-plan.ps1"
$AttestPs1 = Join-Path $ScriptDir $AttestationCommand
if (Test-Path -LiteralPath $AttestPs1 -PathType Leaf) {
$AttestOutput = @(& $AttestPs1 2>&1)
$AttestExitCode = $LASTEXITCODE
if ($AttestExitCode -eq 0) {
$AttestationSucceeded = $true
$AttestationReason = ""
} else {
$AttestationReason = Format-AttestationFailureReason `
-Output $AttestOutput `
-Fallback "$AttestationCommand exited with code $AttestExitCode"
}
} else {
$AttestationReason = "$AttestationCommand was not found beside init-session.ps1"
}
} else {
$AttestSh = Join-Path $ScriptDir "attest-plan.sh"
$AttestationCommand = "attest-plan.sh"
$AttestSh = Join-Path $ScriptDir $AttestationCommand
$Sh = Get-Command sh -ErrorAction SilentlyContinue
if ($Sh -and (Test-Path -LiteralPath $AttestSh)) {
& $Sh.Path $AttestSh *> $null
if ($Sh -and (Test-Path -LiteralPath $AttestSh -PathType Leaf)) {
$AttestOutput = @(& $Sh.Path $AttestSh 2>&1)
$AttestExitCode = $LASTEXITCODE
if ($AttestExitCode -eq 0) {
$AttestationSucceeded = $true
$AttestationReason = ""
} else {
$AttestationReason = Format-AttestationFailureReason `
-Output $AttestOutput `
-Fallback "$AttestationCommand exited with code $AttestExitCode"
}
} elseif (-not $Sh) {
$AttestationReason = "sh was not found; $AttestationCommand could not run"
} else {
$AttestationReason = "$AttestationCommand was not found beside init-session.ps1"
}
}
} catch {
# attestation failure must not abort init; the mode marker still stands.
$AttestationReason = Format-AttestationFailureReason `
-Output @($_) `
-Fallback "$AttestationCommand failed"
} finally {
if ($HadPlanId) {
$env:PLAN_ID = $PreviousPlanId
@@ -399,5 +510,9 @@ if ($Mode -ne "") {
}
}
Write-Host "Mode: $MarkerText (attested, gate counter reset)"
if ($AttestationSucceeded) {
Write-Host "Mode: $MarkerText (attested, gate counter reset)"
} else {
Write-Host "Mode: $MarkerText (NOT attested: $AttestationReason; run $AttestationCommand before the first hook fire)"
}
}
@@ -186,6 +186,10 @@ apply_v3_mode() {
_mode_plan="$2"
[ -z "$MODE" ] && return 0
ATTESTATION_OK=0
ATTESTATION_COMMAND="attest-plan.sh"
ATTESTATION_REASON="task_plan.md was not available for attestation"
# (a) reset the gate block counter and drop any stale gate ledger so a prior
# run's high block count cannot let the next run stop instantly.
printf '0\n' > "${_mode_dir}/.stop_blocks"
@@ -210,13 +214,54 @@ apply_v3_mode() {
# ./task_plan.md when no selector is set, and a bound pin would make it
# refuse the root plan. Run from the project root (CWD here) so both
# resolutions land.
_attest="${SCRIPT_DIR}/attest-plan.sh"
if [ -f "${_attest}" ] && [ -f "${_mode_plan}" ]; then
if [ "$SLUG_MODE" -eq 1 ]; then
PWF_PLAN_ROOT="$PWD" PLAN_ID="${PLAN_ID}" sh "${_attest}" >/dev/null 2>&1 || true
_attest="${SCRIPT_DIR}/${ATTESTATION_COMMAND}"
if [ ! -f "${_attest}" ]; then
ATTESTATION_REASON="${ATTESTATION_COMMAND} was not found beside init-session.sh"
return 0
fi
if [ ! -f "${_mode_plan}" ]; then
return 0
fi
if [ "$SLUG_MODE" -eq 1 ]; then
if _attest_output="$(PWF_PLAN_ROOT="$PWD" PLAN_ID="${PLAN_ID}" sh "${_attest}" 2>&1)"; then
ATTESTATION_OK=1
ATTESTATION_REASON=""
return 0
else
PWF_PLAN_ROOT="" PLAN_ID="" sh "${_attest}" >/dev/null 2>&1 || true
_attest_rc=$?
fi
else
if _attest_output="$(PWF_PLAN_ROOT="" PLAN_ID="" sh "${_attest}" 2>&1)"; then
ATTESTATION_OK=1
ATTESTATION_REASON=""
return 0
else
_attest_rc=$?
fi
fi
_attest_reason="$(
printf '%s\n' "${_attest_output}" |
sed -n '/[^[:space:]]/ { s/[[:space:]][[:space:]]*/ /g; s/^ //; s/ $//; p; q; }' |
cut -c1-300
)"
if [ -n "${_attest_reason}" ]; then
ATTESTATION_REASON="${_attest_reason}"
else
ATTESTATION_REASON="${ATTESTATION_COMMAND} exited with code ${_attest_rc}"
fi
return 0
}
print_v3_mode_status() {
_status_dir="$1"
_marker="$(cat "${_status_dir}/.mode")"
if [ "${ATTESTATION_OK:-0}" -eq 1 ]; then
printf 'Mode: %s (attested, gate counter reset)\n' "${_marker}"
else
printf 'Mode: %s (NOT attested: %s; run %s before the first hook fire)\n' \
"${_marker}" "${ATTESTATION_REASON:-attestation failed}" "${ATTESTATION_COMMAND:-attest-plan.sh}"
fi
}
@@ -431,7 +476,7 @@ if [ "$SLUG_MODE" -eq 1 ]; then
echo "Pin this terminal to the plan for parallel sessions:"
echo " export PLAN_ID=$PLAN_ID"
if [ -n "$MODE" ]; then
echo "Mode: $(cat "${PLAN_DIR}/.mode") (attested, gate counter reset)"
print_v3_mode_status "${PLAN_DIR}"
fi
else
PROJECT_NAME="${PROJECT_NAME:-project}"
@@ -442,6 +487,6 @@ else
echo "Planning files initialized!"
echo "Files: task_plan.md, findings.md, progress.md"
if [ -n "$MODE" ]; then
echo "Mode: $(cat "$(pwd)/.mode") (attested, gate counter reset)"
print_v3_mode_status "$(pwd)"
fi
fi
@@ -7,9 +7,10 @@
The ONLY sanctioned concurrent-safe writer of task_plan.md status lines. The
orchestrator owns task_plan.md; workers NEVER edit it directly. The edit is
a read-modify-write under the portable
<plan-dir>\.pwf-locks\phase-status.lock directory lock, with an atomic
temp-file + move swap so a torn write can never leave a half-rewritten plan
on disk (architecture C4).
<plan-dir>\.pwf-locks\phase-status.lock namespace. Ownership is granted by
atomically creating its .owner file, rather than trusting an external mkdir
exit status. The plan rewrite uses an atomic temp-file + move swap so a torn
write can never leave a half-rewritten plan on disk (architecture C4).
Editing task_plan.md changes its SHA, so the orchestrator must re-attest at
phase boundaries (see attest-plan.ps1).
@@ -102,18 +103,44 @@ function Enter-PwfDirectoryLock {
$wait = [Diagnostics.Stopwatch]::StartNew()
while ($wait.Elapsed.TotalSeconds -lt 5) {
$createdByUs = $false
try {
New-Item -Path $LockDir -ItemType Directory -ErrorAction Stop | Out-Null
$createdByUs = $true
[System.IO.File]::WriteAllText($ownerFile, $token + "`n", $utf8NoBom)
[void][System.IO.Directory]::CreateDirectory($LockDir)
} catch {
Start-Sleep -Milliseconds 100
continue
}
$stream = $null
$createdOwner = $false
$claimed = $false
try {
# FileMode.CreateNew is the cross-runtime ownership decision. It
# fails atomically when either the shell or PowerShell writer has
# already claimed .owner, even if both observed mkdir success.
$stream = [System.IO.File]::Open(
$ownerFile,
[System.IO.FileMode]::CreateNew,
[System.IO.FileAccess]::Write,
[System.IO.FileShare]::None
)
$createdOwner = $true
$bytes = $utf8NoBom.GetBytes($token + "`n")
$stream.Write($bytes, 0, $bytes.Length)
$stream.Flush()
$claimed = $true
return [PSCustomObject]@{
Directory = $LockDir
OwnerFile = $ownerFile
Token = $token
}
} catch {
if ($createdByUs) {
# Existing ownership is the normal contention path. Other failures
# remain fail-closed and are retried only within the bounded wait.
} finally {
if ($stream) {
$stream.Dispose()
}
if ($createdOwner -and -not $claimed) {
try {
if ([System.IO.File]::Exists($ownerFile)) {
$ownerValue = [System.IO.File]::ReadAllText($ownerFile).Trim()
@@ -121,13 +148,12 @@ function Enter-PwfDirectoryLock {
[System.IO.File]::Delete($ownerFile)
}
}
[System.IO.Directory]::Delete($LockDir, $false)
} catch {
# Leave any directory we cannot prove is still ours intact.
# Leave any owner file we cannot prove is still ours intact.
}
}
Start-Sleep -Milliseconds 100
}
Start-Sleep -Milliseconds 100
}
return $null
@@ -174,7 +200,7 @@ $planDir = Split-Path -Parent $planFile
$lockRoot = Join-Path $planDir ".pwf-locks"
$lockDir = Join-Path $lockRoot "phase-status.lock"
# Atomic directory creation is the common lock primitive used by both the sh
# Exclusive .owner creation is the common lock primitive used by both the sh
# and PowerShell implementations. Failure to acquire within about five seconds
# is fail-closed: no plan read/rewrite is attempted.
$lock = Enter-PwfDirectoryLock -LockRoot $lockRoot -LockDir $lockDir
@@ -4,9 +4,12 @@
# This is the ONLY sanctioned concurrent-safe writer of task_plan.md status
# lines. The orchestrator owns task_plan.md; workers NEVER edit it directly.
# All status edits go through this read-modify-write under the portable
# <plan-dir>/.pwf-locks/phase-status.lock directory lock, with an atomic
# temp-file + mv swap so a torn write can never leave a half-rewritten plan on
# disk (architecture C4).
# <plan-dir>/.pwf-locks/phase-status.lock namespace. Lock ownership is the
# atomic no-clobber creation of its .owner file; the directory's mkdir exit
# status is only provisional because some Windows-native implementations can
# report success to two simultaneous creators. The plan rewrite still uses an
# atomic temp-file + mv swap so a torn write can never leave a half-rewritten
# plan on disk (architecture C4).
#
# Note: editing task_plan.md changes its SHA, so the orchestrator must
# re-attest at phase boundaries (see attest-plan.sh).
@@ -106,6 +109,22 @@ release_lock() {
LOCK_ACQUIRED=0
}
claim_lock_owner() {
owner_file="${LOCK_DIR}/.owner"
# POSIX noclobber makes this redirection an exclusive create. This is the
# actual ownership decision: a Windows-native mkdir may report success
# after another process won the directory-creation race (issue #281).
if (
umask 077
set -C
printf '%s\n' "${LOCK_TOKEN}" > "${owner_file}"
) 2>/dev/null; then
LOCK_ACQUIRED=1
return 0
fi
return 1
}
acquire_lock() {
mkdir -p "${LOCK_ROOT}" 2>/dev/null || {
printf "[phase-status] Cannot create lock root %s.\n" "${LOCK_ROOT}" >&2
@@ -114,7 +133,13 @@ acquire_lock() {
LOCK_TOKEN="phase-status-$$-$(date +%s 2>/dev/null || printf 0)"
started_at="$(date +%s 2>/dev/null || printf 0)"
attempts=0
while ! mkdir "${LOCK_DIR}" 2>/dev/null; do
while :; do
# Directory creation is intentionally idempotent. The exclusive owner
# file below, not this external utility's exit status, grants the lock.
mkdir "${LOCK_DIR}" 2>/dev/null || true
if [ -d "${LOCK_DIR}" ] && claim_lock_owner; then
return 0
fi
attempts=$((attempts + 1))
now="$(date +%s 2>/dev/null || printf 0)"
if { [ "${started_at}" -gt 0 ] 2>/dev/null \
@@ -125,13 +150,6 @@ acquire_lock() {
fi
sleep 0.1
done
if ! printf '%s\n' "${LOCK_TOKEN}" > "${LOCK_DIR}/.owner" 2>/dev/null; then
rmdir "${LOCK_DIR}" 2>/dev/null || true
printf "[phase-status] Cannot record lock ownership in %s.\n" "${LOCK_DIR}" >&2
return 1
fi
LOCK_ACQUIRED=1
return 0
}
trap 'release_lock' EXIT
@@ -468,10 +468,13 @@ def _opencode_state_annotation(state: Any) -> str:
def _format_opencode_part(data: Dict[str, Any], session_id: str) -> Optional[Dict[str, Any]]:
"""Print-ready summary for one OpenCode part row."""
if not isinstance(data, dict):
return None
ptype = data.get('type')
short = safe_session_label(session_id)
if ptype == 'tool':
tool = (data.get('tool') or '').lower()
tool_value = data.get('tool')
tool = tool_value.lower() if isinstance(tool_value, str) else ''
state = data.get('state') or {}
input_ = state.get('input') if isinstance(state, dict) else None
input_ = input_ if isinstance(input_, dict) else {}
@@ -486,7 +489,8 @@ def _format_opencode_part(data: Dict[str, Any], session_id: str) -> Optional[Dic
return {'session': short, 'summary': f"Tool bash: {cmd}{outcome}"}
return {'session': short, 'summary': f"Tool {tool}{outcome}"}
if ptype == 'text':
text = (data.get('text') or '')[:300]
text_value = data.get('text')
text = text_value[:300] if isinstance(text_value, str) else ''
if text.strip():
return {'session': short, 'summary': f"text: {text}"}
return None
@@ -128,9 +128,58 @@ function Test-SafeActiveFile {
$linked = ([string]$item.LinkType) -in @('SymbolicLink', 'Junction')
return -not $item.PSIsContainer -and
($AllowLink -or -not $linked) -and
($AllowLink -or -not $item.IsReadOnly) -and
(Test-WithinRoot $ActiveFile)
}
function Test-SafeOwnedBackupFile {
param([string]$Path)
$item = Get-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue
if (-not $item) { return $false }
$linked = ([string]$item.LinkType) -in @('SymbolicLink', 'Junction')
return -not $item.PSIsContainer -and -not $linked -and (Test-WithinRoot $Path)
}
function Resolve-OwnedPointerBackup {
param([string]$BackupFile, [switch]$ReplaceSucceeded)
# ReplaceFile can move the old destination before failing to move the new
# file. A caller-named backup makes that intermediate state attributable to
# this invocation, so recover or delete only this GUID path. Never glob for
# ~RF*.TMP: a concurrent selector may own those files.
$maxAttempts = 20
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
$backupItem = Get-Item -LiteralPath $BackupFile -Force -ErrorAction SilentlyContinue
if (-not $backupItem) { return $true }
# A failed inspection is transient under concurrent writers: retry.
if (-not (Test-SafeOwnedBackupFile $BackupFile)) { Start-Sleep -Milliseconds 25; continue }
$currentActive = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
# After our own successful replacement the backup holds a superseded
# value: only delete it, never move it back over a newer pointer.
if ($ReplaceSucceeded -or $currentActive) {
if (-not $ReplaceSucceeded -and -not (Test-SafeActiveFile)) { Start-Sleep -Milliseconds 25; continue }
try {
[IO.File]::Delete($BackupFile)
return $true
} catch {
if ($attempt -eq $maxAttempts) { return $false }
}
} else {
try {
[IO.File]::Move($BackupFile, $ActiveFile)
return $true
} catch {
# Another selector may have recreated the pointer between the
# absence check and Move. Reinspect before deciding whether the
# owned backup is now redundant.
if ($attempt -eq $maxAttempts) { return $false }
}
}
Start-Sleep -Milliseconds 25
}
return $false
}
function Get-CurrentActivePlan {
if (-not (Test-SafeActiveFile -AllowLink)) { return "" }
try {
@@ -305,9 +354,16 @@ if ($activeItem -and -not (Test-SafeActiveFile)) {
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
# Replace the directory entry instead of truncating an existing inode: a
# hardlinked pointer must not overwrite another file. The exclusive temporary
# file lives beside the pointer, keeping replacement on the same filesystem.
$tempFile = Join-Path $PlanRoot ('.active_plan.' + [guid]::NewGuid().ToString('N') + '.tmp')
# file and caller-owned backup live beside the pointer, keeping replacement on
# the same filesystem. Naming the backup prevents ReplaceFile from inventing
# an unowned ~RF*.TMP path if its final rename fails.
$operationId = [guid]::NewGuid().ToString('N')
$tempFile = Join-Path $PlanRoot ('.active_plan.' + $operationId + '.tmp')
$backupFile = Join-Path $PlanRoot ('.active_plan.' + $operationId + '.replace-backup')
$createdTemp = $false
$failureMessage = ""
$backupResolved = $true
$replaced = $false
try {
$stream = [IO.File]::Open($tempFile, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
$createdTemp = $true
@@ -316,19 +372,48 @@ try {
$stream.Write($bytes, 0, $bytes.Length)
$stream.Flush()
} finally { $stream.Dispose() }
if ($activeItem) {
[IO.File]::Replace($tempFile, $ActiveFile, [NullString]::Value)
} else {
[IO.File]::Move($tempFile, $ActiveFile)
$maxAttempts = 20
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
try {
$currentActive = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
if ($currentActive) {
if (-not (Test-SafeActiveFile)) {
throw [InvalidOperationException]::new("the active plan pointer became unsafe during replacement")
}
[IO.File]::Replace($tempFile, $ActiveFile, $backupFile)
} else {
[IO.File]::Move($tempFile, $ActiveFile)
}
$replaced = $true
break
} catch [IO.IOException] {
if (-not (Resolve-OwnedPointerBackup -BackupFile $backupFile)) {
throw [IO.IOException]::new("could not safely recover the active plan pointer after replacement failure")
}
if ($attempt -eq $maxAttempts) { throw }
Start-Sleep -Milliseconds 25
}
}
} catch {
Write-Error "Error: could not set the active plan pointer: $($_.Exception.Message)"
exit 1
$failureMessage = $_.Exception.Message
} finally {
if (-not (Resolve-OwnedPointerBackup -BackupFile $backupFile -ReplaceSucceeded:$replaced)) {
# The pointer was written; a leftover backup is clutter, not failure.
if ($replaced) { Write-Warning "could not remove the replacement backup $backupFile" }
else { $backupResolved = $false }
}
if ($createdTemp -and (Test-Path -LiteralPath $tempFile)) {
Remove-Item -LiteralPath $tempFile -Force -ErrorAction SilentlyContinue
}
}
if (-not $backupResolved) {
Write-Error "Error: could not safely recover the active plan pointer backup."
exit 1
}
if ($failureMessage) {
Write-Error "Error: could not set the active plan pointer: $failureMessage"
exit 1
}
Write-Output "Active plan set to: $PlanId"
Write-Output "Path: $PlanDir"
Write-Output ""
@@ -8,6 +8,12 @@ State the analytical question or intended deliverable in one clear sentence.
[One sentence describing the analytical objective]
## Next Step
Record the single analytical action that should happen next. Update it whenever the active phase or immediate action changes.
[The single next analytical action. Update whenever phase status changes.]
## Current Phase
Name the phase currently being worked on.
@@ -76,6 +82,6 @@ Record each distinct error, the attempt number, and the resolution. Change the a
## Notes
- Update phase status as work progresses: `pending` to `in_progress` to `complete`.
- Re-read the goal and current phase before major analytical decisions.
- Re-read the goal, next step, and current phase before major analytical decisions.
- Log errors promptly so failed approaches are not repeated.
- Record query results and visual evidence in findings.md.
+1 -1
View File
@@ -10,7 +10,7 @@
"name": "planning-with-files",
"source": "./",
"description": "Persistent project planning files with lifecycle hooks that inject selected plan context. Automatic recovery uses project files only. Explicit catchup modes may read same-project local session records to emit aggregate counts or bounded nonce-framed excerpts. Optional gated mode can request continuation on capable hosts and never runs commands declared in Markdown. No network upload path. Installs across 60+ agents.",
"version": "3.20.4"
"version": "3.21.0"
}
]
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "planning-with-files",
"version": "3.20.4",
"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.4"
version: "3.21.0"
---
@@ -6,16 +6,19 @@
# attested hash, surfacing a "[PLAN TAMPERED]" warning instead.
#
# Resolution:
# 1. $PLAN_ID env var → ./.planning/$PLAN_ID/
# 2. ./.planning/.active_plan
# 3. Newest ./.planning/<dir>/ by mtime
# 4. Current directory when it is .planning/<valid-slug>/
# 5. Legacy ./task_plan.md at project root
# 1. --target root or <plan-id> → root or named plan under $PWF_PLAN_ROOT (default: cwd)
# 2. $PLAN_ID env var → ./.planning/$PLAN_ID/
# 3. ./.planning/.active_plan
# 4. Newest ./.planning/<dir>/ by mtime
# 5. Current directory when it is .planning/<valid-slug>/
# 6. Legacy ./task_plan.md at project root
#
# Usage:
# sh scripts/attest-plan.sh # attest the active plan
# sh scripts/attest-plan.sh --show # print the stored hash
# sh scripts/attest-plan.sh --clear # remove the attestation (re-open the plan)
# sh scripts/attest-plan.sh --target root # attest the legacy root plan
# sh scripts/attest-plan.sh --target <plan-id> # attest one named plan
set -u
@@ -42,6 +45,39 @@ resolve_from_slug_cwd() {
}
resolve_plan_file() {
if [ -n "${target:-}" ]; then
if [ "${target}" = "root" ]; then
target_root="."
if [ -n "${PWF_PLAN_ROOT:-}" ]; then
# Match the shared resolver's supported absolute local pins.
# A valid project-root junction is intentional, so resolve it.
case "${PWF_PLAN_ROOT}" in
\\\\*|//*|[A-Za-z]:[!\\/]*) return 1 ;;
/*|[A-Za-z]:[\\/]*) ;;
*) return 1 ;;
esac
target_root="$(cd "${PWF_PLAN_ROOT}" 2>/dev/null && pwd -P)" || return 1
fi
[ ! -L "${target_root}/task_plan.md" ] || return 1
[ -f "${target_root}/task_plan.md" ] || return 1
printf "%s\n" "${target_root}/task_plan.md"
return 0
fi
slug_is_valid "${target}" || return 1
# Reuse the binding, project-pin and containment rules. A flag replaces
# PLAN_ID for this call only; it does not change the shared pointer.
[ -f "${RESOLVER}" ] || return 1
target_dir="$(PLAN_ID="${target}" sh "${RESOLVER}" 2>/dev/null)" || return 1
[ -n "${target_dir}" ] || return 1
# Use the shell's physical path spelling: native Windows backslashes
# make sha256sum prefix its output with an escaped-filename marker.
target_dir="$(cd "${target_dir}" 2>/dev/null && pwd -P)" || return 1
[ ! -L "${target_dir}/task_plan.md" ] || return 1
[ -f "${target_dir}/task_plan.md" ] || return 1
printf "%s\n" "${target_dir}/task_plan.md"
return 0
fi
plan_dir=""
if [ -f "${RESOLVER}" ]; then
plan_dir="$(sh "${RESOLVER}" 2>/dev/null)"
@@ -80,7 +116,9 @@ resolve_plan_file() {
attestation_path_for() {
plan_file="$1"
plan_dir="$(dirname "${plan_file}")"
if [ "${plan_dir}" = "." ]; then
if [ "${target:-}" = "root" ]; then
printf "%s\n" "${plan_dir}/.plan-attestation"
elif [ "${plan_dir}" = "." ]; then
# Legacy mode: store at project root.
printf "%s\n" "./.plan-attestation"
else
@@ -101,14 +139,21 @@ compute_hash() {
}
mode="attest"
target=""
usage_error() {
printf "Usage: %s [--show|--clear|--target root|<plan-id>]\n" "$0" >&2
exit 2
}
case "${1:-}" in
--show) mode="show" ;;
--clear) mode="clear" ;;
"") mode="attest" ;;
*)
printf "Usage: %s [--show|--clear]\n" "$0" >&2
exit 2
--show) [ "$#" -eq 1 ] || usage_error; mode="show" ;;
--clear) [ "$#" -eq 1 ] || usage_error; mode="clear" ;;
--target)
[ "$#" -eq 2 ] || usage_error
[ -n "$2" ] || usage_error
target="$2"
;;
"") [ "$#" -eq 0 ] || usage_error ;;
*) usage_error ;;
esac
plan_file="$(resolve_plan_file)" || {
@@ -117,7 +162,9 @@ plan_file="$(resolve_plan_file)" || {
# a mistyped PLAN_ID attested a DIFFERENT plan at rc=0, and an operator
# who now sees a generic not-found is likely to go looking for the wrong
# problem. The selectors are bindings, so say which one refused.
if [ -n "${PLAN_ID:-}" ]; then
if [ -n "${target:-}" ]; then
printf "[plan-attest] --target %s did not resolve to a plan. An explicit target is a binding: nothing was attested and no other plan was substituted.\n" "${target}" >&2
elif [ -n "${PLAN_ID:-}" ]; then
printf "[plan-attest] PLAN_ID=%s names no plan directory under .planning. An explicit selector is a binding: nothing was attested and no other plan was substituted.\n" "${PLAN_ID}" >&2
elif [ -n "${PWF_PLAN_ROOT:-}" ]; then
printf "[plan-attest] PWF_PLAN_ROOT=%s did not resolve to a project root holding a plan. An explicit pin is a binding: nothing was attested and no other plan was substituted.\n" "${PWF_PLAN_ROOT}" >&2
@@ -156,6 +203,8 @@ case "${mode}" in
fi
;;
attest)
printf "Plan: %s\n" "${plan_file}"
printf "Attestation: %s\n" "${attestation_file}"
hash_val="$(compute_hash "${plan_file}")" || exit 1
# v2.40: protect the write with an advisory flock when available so
@@ -51,6 +51,8 @@ if ($PlanFile -ne "") {
}
if (-not (Test-Path $PlanFile)) {
# Automatic gate checks have nothing to report without a plan; the explicit report keeps its notice.
if ($Gate) { exit 0 }
Write-Host '[planning-with-files] No task_plan.md found -- no active planning session.'
exit 0
}
@@ -79,6 +79,9 @@ else
fi
if [ ! -f "$PLAN_FILE" ]; then
# Automatic gate checks have nothing to report without a plan (header:
# "plans stay silent in --gate mode"); the explicit report keeps its notice.
[ "$GATE" -eq 1 ] && exit 0
echo "[planning-with-files] No task_plan.md found — no active planning session."
exit 0
fi
@@ -18,6 +18,27 @@ param(
[switch]$Gated
)
# Windows PowerShell 5.1 can silently relocate a -File invocation when the
# inherited cwd contains wildcard characters such as [ or ]. Recover the
# physical cwd for a direct invocation before any project-relative paths are
# resolved.
if ($PSVersionTable.PSVersion.Major -eq 5 -and
[System.Management.Automation.WildcardPattern]::ContainsWildcardCharacters([Environment]::CurrentDirectory)) {
$processArgs = [Environment]::GetCommandLineArgs()
for ($index = 0; $index -lt ($processArgs.Length - 1); $index++) {
if ($processArgs[$index] -ieq '-File') {
$entryScript = $processArgs[$index + 1]
if (-not [IO.Path]::IsPathRooted($entryScript)) {
$entryScript = Join-Path ([Environment]::CurrentDirectory) $entryScript
}
if ([IO.Path]::GetFullPath($entryScript) -eq [IO.Path]::GetFullPath($PSCommandPath)) {
Set-Location -LiteralPath ([Environment]::CurrentDirectory)
}
break
}
}
}
$DATE = Get-Date -Format "yyyy-MM-dd"
# Resolve v3 opt-in mode. -Gated implies autonomous and is the stronger marker.
@@ -80,6 +101,26 @@ function Get-InheritedMode([string]$CurrentMode) {
return $CurrentMode
}
function Format-AttestationFailureReason {
param([object[]]$Output, [string]$Fallback)
$Reason = @(
$Output |
ForEach-Object {
if ($null -ne $_) { ($_.ToString()).Trim() }
} |
Where-Object { $_ }
) -join " "
$Reason = ($Reason -replace '\s+', ' ').Trim()
if ([string]::IsNullOrWhiteSpace($Reason)) {
return $Fallback
}
if ($Reason.Length -gt 300) {
return ($Reason.Substring(0, 297) + "...")
}
return $Reason
}
# Validate template
if ($Template -ne "default" -and $Template -ne "analytics") {
Write-Host "Unknown template: $Template (available: default, analytics). Using default."
@@ -136,20 +177,7 @@ if ($UsePlanDir) {
$Counter++
}
$TargetDir = Join-Path $PlanningRoot $PlanId
New-Item -ItemType Directory -Path $TargetDir -Force | Out-Null
# Reuse the selector's contained, atomic pointer replacement. Set-Content
# would follow a reparse point and truncate a hardlinked pointer in place,
# overwriting whichever file shares that inode.
$global:LASTEXITCODE = 0
try {
& $PlanSelector $PlanId *> $null
} catch {
$global:LASTEXITCODE = 1
}
if ($LASTEXITCODE -ne 0) {
Write-Error "Error: could not safely update the active plan pointer at $(Join-Path $PlanningRoot '.active_plan')."
exit 1
}
New-Item -ItemType Directory -Path $TargetDir -Force -ErrorAction Stop | Out-Null
$Mode = Get-InheritedMode $Mode
} else {
$TargetDir = (Get-Location).Path
@@ -173,11 +201,12 @@ if ($UsePlanDir) {
Write-Host "PLAN_ID=$PlanId"
}
try {
# Create task_plan.md if it doesn't exist
if (-not (Test-Path -LiteralPath $TaskPlanPath)) {
$AnalyticsPlan = Join-Path $TemplateDir "analytics_task_plan.md"
if ($Template -eq "analytics" -and (Test-Path $AnalyticsPlan)) {
Copy-Item -LiteralPath $AnalyticsPlan -Destination $TaskPlanPath
Copy-Item -LiteralPath $AnalyticsPlan -Destination $TaskPlanPath -ErrorAction Stop
} else {
@"
# Task Plan: [Brief Description]
@@ -226,7 +255,7 @@ Phase 1
## Errors Encountered
| Error | Resolution |
|-------|------------|
"@ | Out-File -LiteralPath $TaskPlanPath -Encoding UTF8
"@ | Out-File -LiteralPath $TaskPlanPath -Encoding UTF8 -ErrorAction Stop
}
Write-Host "Created $TaskPlanDisplay"
} else {
@@ -237,7 +266,7 @@ Phase 1
if (-not (Test-Path -LiteralPath $FindingsPath)) {
$AnalyticsFindings = Join-Path $TemplateDir "analytics_findings.md"
if ($Template -eq "analytics" -and (Test-Path $AnalyticsFindings)) {
Copy-Item -LiteralPath $AnalyticsFindings -Destination $FindingsPath
Copy-Item -LiteralPath $AnalyticsFindings -Destination $FindingsPath -ErrorAction Stop
} else {
@"
# Findings & Decisions
@@ -258,7 +287,7 @@ if (-not (Test-Path -LiteralPath $FindingsPath)) {
## Resources
-
"@ | Out-File -LiteralPath $FindingsPath -Encoding UTF8
"@ | Out-File -LiteralPath $FindingsPath -Encoding UTF8 -ErrorAction Stop
}
Write-Host "Created $FindingsDisplay"
} else {
@@ -287,7 +316,7 @@ if (-not (Test-Path -LiteralPath $ProgressPath)) {
### Errors
| Error | Resolution |
|-------|------------|
"@ | Out-File -LiteralPath $ProgressPath -Encoding UTF8
"@ | Out-File -LiteralPath $ProgressPath -Encoding UTF8 -ErrorAction Stop
} else {
@"
# Progress Log
@@ -308,12 +337,58 @@ if (-not (Test-Path -LiteralPath $ProgressPath)) {
### Errors
| Error | Resolution |
|-------|------------|
"@ | Out-File -LiteralPath $ProgressPath -Encoding UTF8
"@ | Out-File -LiteralPath $ProgressPath -Encoding UTF8 -ErrorAction Stop
}
Write-Host "Created $ProgressDisplay"
} else {
Write-Host "$ProgressDisplay already exists, skipping"
}
} catch {
Write-Error "Error: could not initialize planning files in '$TargetDir': $($_.Exception.Message)"
exit 1
}
if ($UsePlanDir) {
# Activate the named plan only after all three planning files are ready.
# This matches init-session.sh and prevents a failed initialization from
# leaving .active_plan pointed at a partial plan directory.
$pointerSet = $false
for ($attempt = 1; $attempt -le 5; $attempt++) {
$global:LASTEXITCODE = 0
try {
$selectorResult = & $PlanSelector $PlanId 2>&1
} catch {
$selectorResult = $_
$global:LASTEXITCODE = 1
}
if ($LASTEXITCODE -eq 0) {
$pointerSet = $true
break
}
# Another writer can replace the pointer between the selector's
# Get-Item and final-path check. Retry only that transient result.
$transientPointerRace = $selectorResult -is [System.Management.Automation.ErrorRecord] -and
$selectorResult.Exception.Message -in @(
'Error: could not set the active plan pointer: the active plan pointer became unsafe during replacement',
'Error: the active plan pointer must be a regular file within the project.'
)
if ($attempt -eq 5 -or -not $transientPointerRace) {
break
}
Start-Sleep -Milliseconds 50
$global:LASTEXITCODE = 0
try {
& $PlanSelector -VerifyRoot *> $null
} catch {
$global:LASTEXITCODE = 1
}
if ($LASTEXITCODE -ne 0) { break }
}
if (-not $pointerSet) {
Write-Error "Error: could not safely update the active plan pointer at $(Join-Path $PlanningRoot '.active_plan')."
exit 1
}
}
Write-Host ""
Write-Host "Planning files initialized!"
@@ -355,8 +430,11 @@ if ($Mode -ne "") {
# to another project or plan (#261, #237). Root mode clears both instead:
# the attester only falls back to the legacy ./task_plan.md when no
# selector is set, and a bound pin would make it refuse the root plan.
$AttestationSucceeded = $false
$AttestationCommand = "attest-plan"
$AttestationReason = "task_plan.md was not available for attestation"
$PlanFilePwf = Join-Path $PlanDirPwf "task_plan.md"
if (Test-Path -LiteralPath $PlanFilePwf) {
if (Test-Path -LiteralPath $PlanFilePwf -PathType Leaf) {
$HadPlanId = Test-Path Env:PLAN_ID
$PreviousPlanId = $env:PLAN_ID
$HadPlanRoot = Test-Path Env:PWF_PLAN_ROOT
@@ -370,21 +448,54 @@ if ($Mode -ne "") {
Remove-Item Env:PLAN_ID -ErrorAction SilentlyContinue
}
# A called script can return without changing $LASTEXITCODE, so
# clear the inherited value before every attempt. Both a non-zero
# status and a terminating exception mean the plan is not attested.
$global:LASTEXITCODE = 0
$AttestOutput = @()
$IsWindowsHost = [Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT
if ($IsWindowsHost) {
$AttestPs1 = Join-Path $ScriptDir "attest-plan.ps1"
if (Test-Path -LiteralPath $AttestPs1) {
& $AttestPs1 *> $null
$AttestationCommand = "attest-plan.ps1"
$AttestPs1 = Join-Path $ScriptDir $AttestationCommand
if (Test-Path -LiteralPath $AttestPs1 -PathType Leaf) {
$AttestOutput = @(& $AttestPs1 2>&1)
$AttestExitCode = $LASTEXITCODE
if ($AttestExitCode -eq 0) {
$AttestationSucceeded = $true
$AttestationReason = ""
} else {
$AttestationReason = Format-AttestationFailureReason `
-Output $AttestOutput `
-Fallback "$AttestationCommand exited with code $AttestExitCode"
}
} else {
$AttestationReason = "$AttestationCommand was not found beside init-session.ps1"
}
} else {
$AttestSh = Join-Path $ScriptDir "attest-plan.sh"
$AttestationCommand = "attest-plan.sh"
$AttestSh = Join-Path $ScriptDir $AttestationCommand
$Sh = Get-Command sh -ErrorAction SilentlyContinue
if ($Sh -and (Test-Path -LiteralPath $AttestSh)) {
& $Sh.Path $AttestSh *> $null
if ($Sh -and (Test-Path -LiteralPath $AttestSh -PathType Leaf)) {
$AttestOutput = @(& $Sh.Path $AttestSh 2>&1)
$AttestExitCode = $LASTEXITCODE
if ($AttestExitCode -eq 0) {
$AttestationSucceeded = $true
$AttestationReason = ""
} else {
$AttestationReason = Format-AttestationFailureReason `
-Output $AttestOutput `
-Fallback "$AttestationCommand exited with code $AttestExitCode"
}
} elseif (-not $Sh) {
$AttestationReason = "sh was not found; $AttestationCommand could not run"
} else {
$AttestationReason = "$AttestationCommand was not found beside init-session.ps1"
}
}
} catch {
# attestation failure must not abort init; the mode marker still stands.
$AttestationReason = Format-AttestationFailureReason `
-Output @($_) `
-Fallback "$AttestationCommand failed"
} finally {
if ($HadPlanId) {
$env:PLAN_ID = $PreviousPlanId
@@ -399,5 +510,9 @@ if ($Mode -ne "") {
}
}
Write-Host "Mode: $MarkerText (attested, gate counter reset)"
if ($AttestationSucceeded) {
Write-Host "Mode: $MarkerText (attested, gate counter reset)"
} else {
Write-Host "Mode: $MarkerText (NOT attested: $AttestationReason; run $AttestationCommand before the first hook fire)"
}
}
@@ -186,6 +186,10 @@ apply_v3_mode() {
_mode_plan="$2"
[ -z "$MODE" ] && return 0
ATTESTATION_OK=0
ATTESTATION_COMMAND="attest-plan.sh"
ATTESTATION_REASON="task_plan.md was not available for attestation"
# (a) reset the gate block counter and drop any stale gate ledger so a prior
# run's high block count cannot let the next run stop instantly.
printf '0\n' > "${_mode_dir}/.stop_blocks"
@@ -210,13 +214,54 @@ apply_v3_mode() {
# ./task_plan.md when no selector is set, and a bound pin would make it
# refuse the root plan. Run from the project root (CWD here) so both
# resolutions land.
_attest="${SCRIPT_DIR}/attest-plan.sh"
if [ -f "${_attest}" ] && [ -f "${_mode_plan}" ]; then
if [ "$SLUG_MODE" -eq 1 ]; then
PWF_PLAN_ROOT="$PWD" PLAN_ID="${PLAN_ID}" sh "${_attest}" >/dev/null 2>&1 || true
_attest="${SCRIPT_DIR}/${ATTESTATION_COMMAND}"
if [ ! -f "${_attest}" ]; then
ATTESTATION_REASON="${ATTESTATION_COMMAND} was not found beside init-session.sh"
return 0
fi
if [ ! -f "${_mode_plan}" ]; then
return 0
fi
if [ "$SLUG_MODE" -eq 1 ]; then
if _attest_output="$(PWF_PLAN_ROOT="$PWD" PLAN_ID="${PLAN_ID}" sh "${_attest}" 2>&1)"; then
ATTESTATION_OK=1
ATTESTATION_REASON=""
return 0
else
PWF_PLAN_ROOT="" PLAN_ID="" sh "${_attest}" >/dev/null 2>&1 || true
_attest_rc=$?
fi
else
if _attest_output="$(PWF_PLAN_ROOT="" PLAN_ID="" sh "${_attest}" 2>&1)"; then
ATTESTATION_OK=1
ATTESTATION_REASON=""
return 0
else
_attest_rc=$?
fi
fi
_attest_reason="$(
printf '%s\n' "${_attest_output}" |
sed -n '/[^[:space:]]/ { s/[[:space:]][[:space:]]*/ /g; s/^ //; s/ $//; p; q; }' |
cut -c1-300
)"
if [ -n "${_attest_reason}" ]; then
ATTESTATION_REASON="${_attest_reason}"
else
ATTESTATION_REASON="${ATTESTATION_COMMAND} exited with code ${_attest_rc}"
fi
return 0
}
print_v3_mode_status() {
_status_dir="$1"
_marker="$(cat "${_status_dir}/.mode")"
if [ "${ATTESTATION_OK:-0}" -eq 1 ]; then
printf 'Mode: %s (attested, gate counter reset)\n' "${_marker}"
else
printf 'Mode: %s (NOT attested: %s; run %s before the first hook fire)\n' \
"${_marker}" "${ATTESTATION_REASON:-attestation failed}" "${ATTESTATION_COMMAND:-attest-plan.sh}"
fi
}
@@ -431,7 +476,7 @@ if [ "$SLUG_MODE" -eq 1 ]; then
echo "Pin this terminal to the plan for parallel sessions:"
echo " export PLAN_ID=$PLAN_ID"
if [ -n "$MODE" ]; then
echo "Mode: $(cat "${PLAN_DIR}/.mode") (attested, gate counter reset)"
print_v3_mode_status "${PLAN_DIR}"
fi
else
PROJECT_NAME="${PROJECT_NAME:-project}"
@@ -442,6 +487,6 @@ else
echo "Planning files initialized!"
echo "Files: task_plan.md, findings.md, progress.md"
if [ -n "$MODE" ]; then
echo "Mode: $(cat "$(pwd)/.mode") (attested, gate counter reset)"
print_v3_mode_status "$(pwd)"
fi
fi
@@ -468,10 +468,13 @@ def _opencode_state_annotation(state: Any) -> str:
def _format_opencode_part(data: Dict[str, Any], session_id: str) -> Optional[Dict[str, Any]]:
"""Print-ready summary for one OpenCode part row."""
if not isinstance(data, dict):
return None
ptype = data.get('type')
short = safe_session_label(session_id)
if ptype == 'tool':
tool = (data.get('tool') or '').lower()
tool_value = data.get('tool')
tool = tool_value.lower() if isinstance(tool_value, str) else ''
state = data.get('state') or {}
input_ = state.get('input') if isinstance(state, dict) else None
input_ = input_ if isinstance(input_, dict) else {}
@@ -486,7 +489,8 @@ def _format_opencode_part(data: Dict[str, Any], session_id: str) -> Optional[Dic
return {'session': short, 'summary': f"Tool bash: {cmd}{outcome}"}
return {'session': short, 'summary': f"Tool {tool}{outcome}"}
if ptype == 'text':
text = (data.get('text') or '')[:300]
text_value = data.get('text')
text = text_value[:300] if isinstance(text_value, str) else ''
if text.strip():
return {'session': short, 'summary': f"text: {text}"}
return None
@@ -128,9 +128,58 @@ function Test-SafeActiveFile {
$linked = ([string]$item.LinkType) -in @('SymbolicLink', 'Junction')
return -not $item.PSIsContainer -and
($AllowLink -or -not $linked) -and
($AllowLink -or -not $item.IsReadOnly) -and
(Test-WithinRoot $ActiveFile)
}
function Test-SafeOwnedBackupFile {
param([string]$Path)
$item = Get-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue
if (-not $item) { return $false }
$linked = ([string]$item.LinkType) -in @('SymbolicLink', 'Junction')
return -not $item.PSIsContainer -and -not $linked -and (Test-WithinRoot $Path)
}
function Resolve-OwnedPointerBackup {
param([string]$BackupFile, [switch]$ReplaceSucceeded)
# ReplaceFile can move the old destination before failing to move the new
# file. A caller-named backup makes that intermediate state attributable to
# this invocation, so recover or delete only this GUID path. Never glob for
# ~RF*.TMP: a concurrent selector may own those files.
$maxAttempts = 20
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
$backupItem = Get-Item -LiteralPath $BackupFile -Force -ErrorAction SilentlyContinue
if (-not $backupItem) { return $true }
# A failed inspection is transient under concurrent writers: retry.
if (-not (Test-SafeOwnedBackupFile $BackupFile)) { Start-Sleep -Milliseconds 25; continue }
$currentActive = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
# After our own successful replacement the backup holds a superseded
# value: only delete it, never move it back over a newer pointer.
if ($ReplaceSucceeded -or $currentActive) {
if (-not $ReplaceSucceeded -and -not (Test-SafeActiveFile)) { Start-Sleep -Milliseconds 25; continue }
try {
[IO.File]::Delete($BackupFile)
return $true
} catch {
if ($attempt -eq $maxAttempts) { return $false }
}
} else {
try {
[IO.File]::Move($BackupFile, $ActiveFile)
return $true
} catch {
# Another selector may have recreated the pointer between the
# absence check and Move. Reinspect before deciding whether the
# owned backup is now redundant.
if ($attempt -eq $maxAttempts) { return $false }
}
}
Start-Sleep -Milliseconds 25
}
return $false
}
function Get-CurrentActivePlan {
if (-not (Test-SafeActiveFile -AllowLink)) { return "" }
try {
@@ -305,9 +354,16 @@ if ($activeItem -and -not (Test-SafeActiveFile)) {
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
# Replace the directory entry instead of truncating an existing inode: a
# hardlinked pointer must not overwrite another file. The exclusive temporary
# file lives beside the pointer, keeping replacement on the same filesystem.
$tempFile = Join-Path $PlanRoot ('.active_plan.' + [guid]::NewGuid().ToString('N') + '.tmp')
# file and caller-owned backup live beside the pointer, keeping replacement on
# the same filesystem. Naming the backup prevents ReplaceFile from inventing
# an unowned ~RF*.TMP path if its final rename fails.
$operationId = [guid]::NewGuid().ToString('N')
$tempFile = Join-Path $PlanRoot ('.active_plan.' + $operationId + '.tmp')
$backupFile = Join-Path $PlanRoot ('.active_plan.' + $operationId + '.replace-backup')
$createdTemp = $false
$failureMessage = ""
$backupResolved = $true
$replaced = $false
try {
$stream = [IO.File]::Open($tempFile, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
$createdTemp = $true
@@ -316,19 +372,48 @@ try {
$stream.Write($bytes, 0, $bytes.Length)
$stream.Flush()
} finally { $stream.Dispose() }
if ($activeItem) {
[IO.File]::Replace($tempFile, $ActiveFile, [NullString]::Value)
} else {
[IO.File]::Move($tempFile, $ActiveFile)
$maxAttempts = 20
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
try {
$currentActive = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
if ($currentActive) {
if (-not (Test-SafeActiveFile)) {
throw [InvalidOperationException]::new("the active plan pointer became unsafe during replacement")
}
[IO.File]::Replace($tempFile, $ActiveFile, $backupFile)
} else {
[IO.File]::Move($tempFile, $ActiveFile)
}
$replaced = $true
break
} catch [IO.IOException] {
if (-not (Resolve-OwnedPointerBackup -BackupFile $backupFile)) {
throw [IO.IOException]::new("could not safely recover the active plan pointer after replacement failure")
}
if ($attempt -eq $maxAttempts) { throw }
Start-Sleep -Milliseconds 25
}
}
} catch {
Write-Error "Error: could not set the active plan pointer: $($_.Exception.Message)"
exit 1
$failureMessage = $_.Exception.Message
} finally {
if (-not (Resolve-OwnedPointerBackup -BackupFile $backupFile -ReplaceSucceeded:$replaced)) {
# The pointer was written; a leftover backup is clutter, not failure.
if ($replaced) { Write-Warning "could not remove the replacement backup $backupFile" }
else { $backupResolved = $false }
}
if ($createdTemp -and (Test-Path -LiteralPath $tempFile)) {
Remove-Item -LiteralPath $tempFile -Force -ErrorAction SilentlyContinue
}
}
if (-not $backupResolved) {
Write-Error "Error: could not safely recover the active plan pointer backup."
exit 1
}
if ($failureMessage) {
Write-Error "Error: could not set the active plan pointer: $failureMessage"
exit 1
}
Write-Output "Active plan set to: $PlanId"
Write-Output "Path: $PlanDir"
Write-Output ""
@@ -8,6 +8,12 @@ State the analytical question or intended deliverable in one clear sentence.
[One sentence describing the analytical objective]
## Next Step
Record the single analytical action that should happen next. Update it whenever the active phase or immediate action changes.
[The single next analytical action. Update whenever phase status changes.]
## Current Phase
Name the phase currently being worked on.
@@ -76,6 +82,6 @@ Record each distinct error, the attempt number, and the resolution. Change the a
## Notes
- Update phase status as work progresses: `pending` to `in_progress` to `complete`.
- Re-read the goal and current phase before major analytical decisions.
- Re-read the goal, next step, and current phase before major analytical decisions.
- Log errors promptly so failed approaches are not repeated.
- Record query results and visual evidence in findings.md.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "planning-with-files",
"version": "3.20.4",
"version": "3.21.0",
"description": "Persistent project planning for Codex. Opt-in lifecycle hooks inject selected plan context; automatic recovery uses project files only. Explicit catchup modes may read same-project local session records to emit aggregate counts or bounded nonce-framed excerpts. Optional gated mode can request continuation on capable hosts and never runs Markdown-declared commands. No network upload path.",
"author": {
"name": "OthmanAdi",
+1 -1
View File
@@ -36,7 +36,7 @@ hooks:
- type: command
command: "SH=\"\"; for c in \"${PWF_SCRIPT_DIR}/skill-hook.sh\" \"${CLAUDE_SKILL_DIR}/scripts/skill-hook.sh\" \"$HOME/.codex/skills/planning-with-files/scripts/skill-hook.sh\" \"$HOME/.claude/skills/planning-with-files/scripts/skill-hook.sh\" \"$HOME/.claude/plugins/marketplaces/planning-with-files/scripts/skill-hook.sh\"; do [ -f \"$c\" ] && { SH=\"$c\"; break; }; done; [ -n \"$SH\" ] && sh \"$SH\" --event=precompact; exit 0"
metadata:
version: "3.20.4"
version: "3.21.0"
---
@@ -6,16 +6,19 @@
# attested hash, surfacing a "[PLAN TAMPERED]" warning instead.
#
# Resolution:
# 1. $PLAN_ID env var → ./.planning/$PLAN_ID/
# 2. ./.planning/.active_plan
# 3. Newest ./.planning/<dir>/ by mtime
# 4. Current directory when it is .planning/<valid-slug>/
# 5. Legacy ./task_plan.md at project root
# 1. --target root or <plan-id> → root or named plan under $PWF_PLAN_ROOT (default: cwd)
# 2. $PLAN_ID env var → ./.planning/$PLAN_ID/
# 3. ./.planning/.active_plan
# 4. Newest ./.planning/<dir>/ by mtime
# 5. Current directory when it is .planning/<valid-slug>/
# 6. Legacy ./task_plan.md at project root
#
# Usage:
# sh scripts/attest-plan.sh # attest the active plan
# sh scripts/attest-plan.sh --show # print the stored hash
# sh scripts/attest-plan.sh --clear # remove the attestation (re-open the plan)
# sh scripts/attest-plan.sh --target root # attest the legacy root plan
# sh scripts/attest-plan.sh --target <plan-id> # attest one named plan
set -u
@@ -42,6 +45,39 @@ resolve_from_slug_cwd() {
}
resolve_plan_file() {
if [ -n "${target:-}" ]; then
if [ "${target}" = "root" ]; then
target_root="."
if [ -n "${PWF_PLAN_ROOT:-}" ]; then
# Match the shared resolver's supported absolute local pins.
# A valid project-root junction is intentional, so resolve it.
case "${PWF_PLAN_ROOT}" in
\\\\*|//*|[A-Za-z]:[!\\/]*) return 1 ;;
/*|[A-Za-z]:[\\/]*) ;;
*) return 1 ;;
esac
target_root="$(cd "${PWF_PLAN_ROOT}" 2>/dev/null && pwd -P)" || return 1
fi
[ ! -L "${target_root}/task_plan.md" ] || return 1
[ -f "${target_root}/task_plan.md" ] || return 1
printf "%s\n" "${target_root}/task_plan.md"
return 0
fi
slug_is_valid "${target}" || return 1
# Reuse the binding, project-pin and containment rules. A flag replaces
# PLAN_ID for this call only; it does not change the shared pointer.
[ -f "${RESOLVER}" ] || return 1
target_dir="$(PLAN_ID="${target}" sh "${RESOLVER}" 2>/dev/null)" || return 1
[ -n "${target_dir}" ] || return 1
# Use the shell's physical path spelling: native Windows backslashes
# make sha256sum prefix its output with an escaped-filename marker.
target_dir="$(cd "${target_dir}" 2>/dev/null && pwd -P)" || return 1
[ ! -L "${target_dir}/task_plan.md" ] || return 1
[ -f "${target_dir}/task_plan.md" ] || return 1
printf "%s\n" "${target_dir}/task_plan.md"
return 0
fi
plan_dir=""
if [ -f "${RESOLVER}" ]; then
plan_dir="$(sh "${RESOLVER}" 2>/dev/null)"
@@ -80,7 +116,9 @@ resolve_plan_file() {
attestation_path_for() {
plan_file="$1"
plan_dir="$(dirname "${plan_file}")"
if [ "${plan_dir}" = "." ]; then
if [ "${target:-}" = "root" ]; then
printf "%s\n" "${plan_dir}/.plan-attestation"
elif [ "${plan_dir}" = "." ]; then
# Legacy mode: store at project root.
printf "%s\n" "./.plan-attestation"
else
@@ -101,14 +139,21 @@ compute_hash() {
}
mode="attest"
target=""
usage_error() {
printf "Usage: %s [--show|--clear|--target root|<plan-id>]\n" "$0" >&2
exit 2
}
case "${1:-}" in
--show) mode="show" ;;
--clear) mode="clear" ;;
"") mode="attest" ;;
*)
printf "Usage: %s [--show|--clear]\n" "$0" >&2
exit 2
--show) [ "$#" -eq 1 ] || usage_error; mode="show" ;;
--clear) [ "$#" -eq 1 ] || usage_error; mode="clear" ;;
--target)
[ "$#" -eq 2 ] || usage_error
[ -n "$2" ] || usage_error
target="$2"
;;
"") [ "$#" -eq 0 ] || usage_error ;;
*) usage_error ;;
esac
plan_file="$(resolve_plan_file)" || {
@@ -117,7 +162,9 @@ plan_file="$(resolve_plan_file)" || {
# a mistyped PLAN_ID attested a DIFFERENT plan at rc=0, and an operator
# who now sees a generic not-found is likely to go looking for the wrong
# problem. The selectors are bindings, so say which one refused.
if [ -n "${PLAN_ID:-}" ]; then
if [ -n "${target:-}" ]; then
printf "[plan-attest] --target %s did not resolve to a plan. An explicit target is a binding: nothing was attested and no other plan was substituted.\n" "${target}" >&2
elif [ -n "${PLAN_ID:-}" ]; then
printf "[plan-attest] PLAN_ID=%s names no plan directory under .planning. An explicit selector is a binding: nothing was attested and no other plan was substituted.\n" "${PLAN_ID}" >&2
elif [ -n "${PWF_PLAN_ROOT:-}" ]; then
printf "[plan-attest] PWF_PLAN_ROOT=%s did not resolve to a project root holding a plan. An explicit pin is a binding: nothing was attested and no other plan was substituted.\n" "${PWF_PLAN_ROOT}" >&2
@@ -156,6 +203,8 @@ case "${mode}" in
fi
;;
attest)
printf "Plan: %s\n" "${plan_file}"
printf "Attestation: %s\n" "${attestation_file}"
hash_val="$(compute_hash "${plan_file}")" || exit 1
# v2.40: protect the write with an advisory flock when available so
@@ -51,6 +51,8 @@ if ($PlanFile -ne "") {
}
if (-not (Test-Path $PlanFile)) {
# Automatic gate checks have nothing to report without a plan; the explicit report keeps its notice.
if ($Gate) { exit 0 }
Write-Host '[planning-with-files] No task_plan.md found -- no active planning session.'
exit 0
}
@@ -79,6 +79,9 @@ else
fi
if [ ! -f "$PLAN_FILE" ]; then
# Automatic gate checks have nothing to report without a plan (header:
# "plans stay silent in --gate mode"); the explicit report keeps its notice.
[ "$GATE" -eq 1 ] && exit 0
echo "[planning-with-files] No task_plan.md found — no active planning session."
exit 0
fi
@@ -18,6 +18,27 @@ param(
[switch]$Gated
)
# Windows PowerShell 5.1 can silently relocate a -File invocation when the
# inherited cwd contains wildcard characters such as [ or ]. Recover the
# physical cwd for a direct invocation before any project-relative paths are
# resolved.
if ($PSVersionTable.PSVersion.Major -eq 5 -and
[System.Management.Automation.WildcardPattern]::ContainsWildcardCharacters([Environment]::CurrentDirectory)) {
$processArgs = [Environment]::GetCommandLineArgs()
for ($index = 0; $index -lt ($processArgs.Length - 1); $index++) {
if ($processArgs[$index] -ieq '-File') {
$entryScript = $processArgs[$index + 1]
if (-not [IO.Path]::IsPathRooted($entryScript)) {
$entryScript = Join-Path ([Environment]::CurrentDirectory) $entryScript
}
if ([IO.Path]::GetFullPath($entryScript) -eq [IO.Path]::GetFullPath($PSCommandPath)) {
Set-Location -LiteralPath ([Environment]::CurrentDirectory)
}
break
}
}
}
$DATE = Get-Date -Format "yyyy-MM-dd"
# Resolve v3 opt-in mode. -Gated implies autonomous and is the stronger marker.
@@ -80,6 +101,26 @@ function Get-InheritedMode([string]$CurrentMode) {
return $CurrentMode
}
function Format-AttestationFailureReason {
param([object[]]$Output, [string]$Fallback)
$Reason = @(
$Output |
ForEach-Object {
if ($null -ne $_) { ($_.ToString()).Trim() }
} |
Where-Object { $_ }
) -join " "
$Reason = ($Reason -replace '\s+', ' ').Trim()
if ([string]::IsNullOrWhiteSpace($Reason)) {
return $Fallback
}
if ($Reason.Length -gt 300) {
return ($Reason.Substring(0, 297) + "...")
}
return $Reason
}
# Validate template
if ($Template -ne "default" -and $Template -ne "analytics") {
Write-Host "Unknown template: $Template (available: default, analytics). Using default."
@@ -136,20 +177,7 @@ if ($UsePlanDir) {
$Counter++
}
$TargetDir = Join-Path $PlanningRoot $PlanId
New-Item -ItemType Directory -Path $TargetDir -Force | Out-Null
# Reuse the selector's contained, atomic pointer replacement. Set-Content
# would follow a reparse point and truncate a hardlinked pointer in place,
# overwriting whichever file shares that inode.
$global:LASTEXITCODE = 0
try {
& $PlanSelector $PlanId *> $null
} catch {
$global:LASTEXITCODE = 1
}
if ($LASTEXITCODE -ne 0) {
Write-Error "Error: could not safely update the active plan pointer at $(Join-Path $PlanningRoot '.active_plan')."
exit 1
}
New-Item -ItemType Directory -Path $TargetDir -Force -ErrorAction Stop | Out-Null
$Mode = Get-InheritedMode $Mode
} else {
$TargetDir = (Get-Location).Path
@@ -173,11 +201,12 @@ if ($UsePlanDir) {
Write-Host "PLAN_ID=$PlanId"
}
try {
# Create task_plan.md if it doesn't exist
if (-not (Test-Path -LiteralPath $TaskPlanPath)) {
$AnalyticsPlan = Join-Path $TemplateDir "analytics_task_plan.md"
if ($Template -eq "analytics" -and (Test-Path $AnalyticsPlan)) {
Copy-Item -LiteralPath $AnalyticsPlan -Destination $TaskPlanPath
Copy-Item -LiteralPath $AnalyticsPlan -Destination $TaskPlanPath -ErrorAction Stop
} else {
@"
# Task Plan: [Brief Description]
@@ -226,7 +255,7 @@ Phase 1
## Errors Encountered
| Error | Resolution |
|-------|------------|
"@ | Out-File -LiteralPath $TaskPlanPath -Encoding UTF8
"@ | Out-File -LiteralPath $TaskPlanPath -Encoding UTF8 -ErrorAction Stop
}
Write-Host "Created $TaskPlanDisplay"
} else {
@@ -237,7 +266,7 @@ Phase 1
if (-not (Test-Path -LiteralPath $FindingsPath)) {
$AnalyticsFindings = Join-Path $TemplateDir "analytics_findings.md"
if ($Template -eq "analytics" -and (Test-Path $AnalyticsFindings)) {
Copy-Item -LiteralPath $AnalyticsFindings -Destination $FindingsPath
Copy-Item -LiteralPath $AnalyticsFindings -Destination $FindingsPath -ErrorAction Stop
} else {
@"
# Findings & Decisions
@@ -258,7 +287,7 @@ if (-not (Test-Path -LiteralPath $FindingsPath)) {
## Resources
-
"@ | Out-File -LiteralPath $FindingsPath -Encoding UTF8
"@ | Out-File -LiteralPath $FindingsPath -Encoding UTF8 -ErrorAction Stop
}
Write-Host "Created $FindingsDisplay"
} else {
@@ -287,7 +316,7 @@ if (-not (Test-Path -LiteralPath $ProgressPath)) {
### Errors
| Error | Resolution |
|-------|------------|
"@ | Out-File -LiteralPath $ProgressPath -Encoding UTF8
"@ | Out-File -LiteralPath $ProgressPath -Encoding UTF8 -ErrorAction Stop
} else {
@"
# Progress Log
@@ -308,12 +337,58 @@ if (-not (Test-Path -LiteralPath $ProgressPath)) {
### Errors
| Error | Resolution |
|-------|------------|
"@ | Out-File -LiteralPath $ProgressPath -Encoding UTF8
"@ | Out-File -LiteralPath $ProgressPath -Encoding UTF8 -ErrorAction Stop
}
Write-Host "Created $ProgressDisplay"
} else {
Write-Host "$ProgressDisplay already exists, skipping"
}
} catch {
Write-Error "Error: could not initialize planning files in '$TargetDir': $($_.Exception.Message)"
exit 1
}
if ($UsePlanDir) {
# Activate the named plan only after all three planning files are ready.
# This matches init-session.sh and prevents a failed initialization from
# leaving .active_plan pointed at a partial plan directory.
$pointerSet = $false
for ($attempt = 1; $attempt -le 5; $attempt++) {
$global:LASTEXITCODE = 0
try {
$selectorResult = & $PlanSelector $PlanId 2>&1
} catch {
$selectorResult = $_
$global:LASTEXITCODE = 1
}
if ($LASTEXITCODE -eq 0) {
$pointerSet = $true
break
}
# Another writer can replace the pointer between the selector's
# Get-Item and final-path check. Retry only that transient result.
$transientPointerRace = $selectorResult -is [System.Management.Automation.ErrorRecord] -and
$selectorResult.Exception.Message -in @(
'Error: could not set the active plan pointer: the active plan pointer became unsafe during replacement',
'Error: the active plan pointer must be a regular file within the project.'
)
if ($attempt -eq 5 -or -not $transientPointerRace) {
break
}
Start-Sleep -Milliseconds 50
$global:LASTEXITCODE = 0
try {
& $PlanSelector -VerifyRoot *> $null
} catch {
$global:LASTEXITCODE = 1
}
if ($LASTEXITCODE -ne 0) { break }
}
if (-not $pointerSet) {
Write-Error "Error: could not safely update the active plan pointer at $(Join-Path $PlanningRoot '.active_plan')."
exit 1
}
}
Write-Host ""
Write-Host "Planning files initialized!"
@@ -355,8 +430,11 @@ if ($Mode -ne "") {
# to another project or plan (#261, #237). Root mode clears both instead:
# the attester only falls back to the legacy ./task_plan.md when no
# selector is set, and a bound pin would make it refuse the root plan.
$AttestationSucceeded = $false
$AttestationCommand = "attest-plan"
$AttestationReason = "task_plan.md was not available for attestation"
$PlanFilePwf = Join-Path $PlanDirPwf "task_plan.md"
if (Test-Path -LiteralPath $PlanFilePwf) {
if (Test-Path -LiteralPath $PlanFilePwf -PathType Leaf) {
$HadPlanId = Test-Path Env:PLAN_ID
$PreviousPlanId = $env:PLAN_ID
$HadPlanRoot = Test-Path Env:PWF_PLAN_ROOT
@@ -370,21 +448,54 @@ if ($Mode -ne "") {
Remove-Item Env:PLAN_ID -ErrorAction SilentlyContinue
}
# A called script can return without changing $LASTEXITCODE, so
# clear the inherited value before every attempt. Both a non-zero
# status and a terminating exception mean the plan is not attested.
$global:LASTEXITCODE = 0
$AttestOutput = @()
$IsWindowsHost = [Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT
if ($IsWindowsHost) {
$AttestPs1 = Join-Path $ScriptDir "attest-plan.ps1"
if (Test-Path -LiteralPath $AttestPs1) {
& $AttestPs1 *> $null
$AttestationCommand = "attest-plan.ps1"
$AttestPs1 = Join-Path $ScriptDir $AttestationCommand
if (Test-Path -LiteralPath $AttestPs1 -PathType Leaf) {
$AttestOutput = @(& $AttestPs1 2>&1)
$AttestExitCode = $LASTEXITCODE
if ($AttestExitCode -eq 0) {
$AttestationSucceeded = $true
$AttestationReason = ""
} else {
$AttestationReason = Format-AttestationFailureReason `
-Output $AttestOutput `
-Fallback "$AttestationCommand exited with code $AttestExitCode"
}
} else {
$AttestationReason = "$AttestationCommand was not found beside init-session.ps1"
}
} else {
$AttestSh = Join-Path $ScriptDir "attest-plan.sh"
$AttestationCommand = "attest-plan.sh"
$AttestSh = Join-Path $ScriptDir $AttestationCommand
$Sh = Get-Command sh -ErrorAction SilentlyContinue
if ($Sh -and (Test-Path -LiteralPath $AttestSh)) {
& $Sh.Path $AttestSh *> $null
if ($Sh -and (Test-Path -LiteralPath $AttestSh -PathType Leaf)) {
$AttestOutput = @(& $Sh.Path $AttestSh 2>&1)
$AttestExitCode = $LASTEXITCODE
if ($AttestExitCode -eq 0) {
$AttestationSucceeded = $true
$AttestationReason = ""
} else {
$AttestationReason = Format-AttestationFailureReason `
-Output $AttestOutput `
-Fallback "$AttestationCommand exited with code $AttestExitCode"
}
} elseif (-not $Sh) {
$AttestationReason = "sh was not found; $AttestationCommand could not run"
} else {
$AttestationReason = "$AttestationCommand was not found beside init-session.ps1"
}
}
} catch {
# attestation failure must not abort init; the mode marker still stands.
$AttestationReason = Format-AttestationFailureReason `
-Output @($_) `
-Fallback "$AttestationCommand failed"
} finally {
if ($HadPlanId) {
$env:PLAN_ID = $PreviousPlanId
@@ -399,5 +510,9 @@ if ($Mode -ne "") {
}
}
Write-Host "Mode: $MarkerText (attested, gate counter reset)"
if ($AttestationSucceeded) {
Write-Host "Mode: $MarkerText (attested, gate counter reset)"
} else {
Write-Host "Mode: $MarkerText (NOT attested: $AttestationReason; run $AttestationCommand before the first hook fire)"
}
}
@@ -186,6 +186,10 @@ apply_v3_mode() {
_mode_plan="$2"
[ -z "$MODE" ] && return 0
ATTESTATION_OK=0
ATTESTATION_COMMAND="attest-plan.sh"
ATTESTATION_REASON="task_plan.md was not available for attestation"
# (a) reset the gate block counter and drop any stale gate ledger so a prior
# run's high block count cannot let the next run stop instantly.
printf '0\n' > "${_mode_dir}/.stop_blocks"
@@ -210,13 +214,54 @@ apply_v3_mode() {
# ./task_plan.md when no selector is set, and a bound pin would make it
# refuse the root plan. Run from the project root (CWD here) so both
# resolutions land.
_attest="${SCRIPT_DIR}/attest-plan.sh"
if [ -f "${_attest}" ] && [ -f "${_mode_plan}" ]; then
if [ "$SLUG_MODE" -eq 1 ]; then
PWF_PLAN_ROOT="$PWD" PLAN_ID="${PLAN_ID}" sh "${_attest}" >/dev/null 2>&1 || true
_attest="${SCRIPT_DIR}/${ATTESTATION_COMMAND}"
if [ ! -f "${_attest}" ]; then
ATTESTATION_REASON="${ATTESTATION_COMMAND} was not found beside init-session.sh"
return 0
fi
if [ ! -f "${_mode_plan}" ]; then
return 0
fi
if [ "$SLUG_MODE" -eq 1 ]; then
if _attest_output="$(PWF_PLAN_ROOT="$PWD" PLAN_ID="${PLAN_ID}" sh "${_attest}" 2>&1)"; then
ATTESTATION_OK=1
ATTESTATION_REASON=""
return 0
else
PWF_PLAN_ROOT="" PLAN_ID="" sh "${_attest}" >/dev/null 2>&1 || true
_attest_rc=$?
fi
else
if _attest_output="$(PWF_PLAN_ROOT="" PLAN_ID="" sh "${_attest}" 2>&1)"; then
ATTESTATION_OK=1
ATTESTATION_REASON=""
return 0
else
_attest_rc=$?
fi
fi
_attest_reason="$(
printf '%s\n' "${_attest_output}" |
sed -n '/[^[:space:]]/ { s/[[:space:]][[:space:]]*/ /g; s/^ //; s/ $//; p; q; }' |
cut -c1-300
)"
if [ -n "${_attest_reason}" ]; then
ATTESTATION_REASON="${_attest_reason}"
else
ATTESTATION_REASON="${ATTESTATION_COMMAND} exited with code ${_attest_rc}"
fi
return 0
}
print_v3_mode_status() {
_status_dir="$1"
_marker="$(cat "${_status_dir}/.mode")"
if [ "${ATTESTATION_OK:-0}" -eq 1 ]; then
printf 'Mode: %s (attested, gate counter reset)\n' "${_marker}"
else
printf 'Mode: %s (NOT attested: %s; run %s before the first hook fire)\n' \
"${_marker}" "${ATTESTATION_REASON:-attestation failed}" "${ATTESTATION_COMMAND:-attest-plan.sh}"
fi
}
@@ -431,7 +476,7 @@ if [ "$SLUG_MODE" -eq 1 ]; then
echo "Pin this terminal to the plan for parallel sessions:"
echo " export PLAN_ID=$PLAN_ID"
if [ -n "$MODE" ]; then
echo "Mode: $(cat "${PLAN_DIR}/.mode") (attested, gate counter reset)"
print_v3_mode_status "${PLAN_DIR}"
fi
else
PROJECT_NAME="${PROJECT_NAME:-project}"
@@ -442,6 +487,6 @@ else
echo "Planning files initialized!"
echo "Files: task_plan.md, findings.md, progress.md"
if [ -n "$MODE" ]; then
echo "Mode: $(cat "$(pwd)/.mode") (attested, gate counter reset)"
print_v3_mode_status "$(pwd)"
fi
fi
@@ -468,10 +468,13 @@ def _opencode_state_annotation(state: Any) -> str:
def _format_opencode_part(data: Dict[str, Any], session_id: str) -> Optional[Dict[str, Any]]:
"""Print-ready summary for one OpenCode part row."""
if not isinstance(data, dict):
return None
ptype = data.get('type')
short = safe_session_label(session_id)
if ptype == 'tool':
tool = (data.get('tool') or '').lower()
tool_value = data.get('tool')
tool = tool_value.lower() if isinstance(tool_value, str) else ''
state = data.get('state') or {}
input_ = state.get('input') if isinstance(state, dict) else None
input_ = input_ if isinstance(input_, dict) else {}
@@ -486,7 +489,8 @@ def _format_opencode_part(data: Dict[str, Any], session_id: str) -> Optional[Dic
return {'session': short, 'summary': f"Tool bash: {cmd}{outcome}"}
return {'session': short, 'summary': f"Tool {tool}{outcome}"}
if ptype == 'text':
text = (data.get('text') or '')[:300]
text_value = data.get('text')
text = text_value[:300] if isinstance(text_value, str) else ''
if text.strip():
return {'session': short, 'summary': f"text: {text}"}
return None
@@ -128,9 +128,58 @@ function Test-SafeActiveFile {
$linked = ([string]$item.LinkType) -in @('SymbolicLink', 'Junction')
return -not $item.PSIsContainer -and
($AllowLink -or -not $linked) -and
($AllowLink -or -not $item.IsReadOnly) -and
(Test-WithinRoot $ActiveFile)
}
function Test-SafeOwnedBackupFile {
param([string]$Path)
$item = Get-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue
if (-not $item) { return $false }
$linked = ([string]$item.LinkType) -in @('SymbolicLink', 'Junction')
return -not $item.PSIsContainer -and -not $linked -and (Test-WithinRoot $Path)
}
function Resolve-OwnedPointerBackup {
param([string]$BackupFile, [switch]$ReplaceSucceeded)
# ReplaceFile can move the old destination before failing to move the new
# file. A caller-named backup makes that intermediate state attributable to
# this invocation, so recover or delete only this GUID path. Never glob for
# ~RF*.TMP: a concurrent selector may own those files.
$maxAttempts = 20
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
$backupItem = Get-Item -LiteralPath $BackupFile -Force -ErrorAction SilentlyContinue
if (-not $backupItem) { return $true }
# A failed inspection is transient under concurrent writers: retry.
if (-not (Test-SafeOwnedBackupFile $BackupFile)) { Start-Sleep -Milliseconds 25; continue }
$currentActive = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
# After our own successful replacement the backup holds a superseded
# value: only delete it, never move it back over a newer pointer.
if ($ReplaceSucceeded -or $currentActive) {
if (-not $ReplaceSucceeded -and -not (Test-SafeActiveFile)) { Start-Sleep -Milliseconds 25; continue }
try {
[IO.File]::Delete($BackupFile)
return $true
} catch {
if ($attempt -eq $maxAttempts) { return $false }
}
} else {
try {
[IO.File]::Move($BackupFile, $ActiveFile)
return $true
} catch {
# Another selector may have recreated the pointer between the
# absence check and Move. Reinspect before deciding whether the
# owned backup is now redundant.
if ($attempt -eq $maxAttempts) { return $false }
}
}
Start-Sleep -Milliseconds 25
}
return $false
}
function Get-CurrentActivePlan {
if (-not (Test-SafeActiveFile -AllowLink)) { return "" }
try {
@@ -305,9 +354,16 @@ if ($activeItem -and -not (Test-SafeActiveFile)) {
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
# Replace the directory entry instead of truncating an existing inode: a
# hardlinked pointer must not overwrite another file. The exclusive temporary
# file lives beside the pointer, keeping replacement on the same filesystem.
$tempFile = Join-Path $PlanRoot ('.active_plan.' + [guid]::NewGuid().ToString('N') + '.tmp')
# file and caller-owned backup live beside the pointer, keeping replacement on
# the same filesystem. Naming the backup prevents ReplaceFile from inventing
# an unowned ~RF*.TMP path if its final rename fails.
$operationId = [guid]::NewGuid().ToString('N')
$tempFile = Join-Path $PlanRoot ('.active_plan.' + $operationId + '.tmp')
$backupFile = Join-Path $PlanRoot ('.active_plan.' + $operationId + '.replace-backup')
$createdTemp = $false
$failureMessage = ""
$backupResolved = $true
$replaced = $false
try {
$stream = [IO.File]::Open($tempFile, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
$createdTemp = $true
@@ -316,19 +372,48 @@ try {
$stream.Write($bytes, 0, $bytes.Length)
$stream.Flush()
} finally { $stream.Dispose() }
if ($activeItem) {
[IO.File]::Replace($tempFile, $ActiveFile, [NullString]::Value)
} else {
[IO.File]::Move($tempFile, $ActiveFile)
$maxAttempts = 20
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
try {
$currentActive = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
if ($currentActive) {
if (-not (Test-SafeActiveFile)) {
throw [InvalidOperationException]::new("the active plan pointer became unsafe during replacement")
}
[IO.File]::Replace($tempFile, $ActiveFile, $backupFile)
} else {
[IO.File]::Move($tempFile, $ActiveFile)
}
$replaced = $true
break
} catch [IO.IOException] {
if (-not (Resolve-OwnedPointerBackup -BackupFile $backupFile)) {
throw [IO.IOException]::new("could not safely recover the active plan pointer after replacement failure")
}
if ($attempt -eq $maxAttempts) { throw }
Start-Sleep -Milliseconds 25
}
}
} catch {
Write-Error "Error: could not set the active plan pointer: $($_.Exception.Message)"
exit 1
$failureMessage = $_.Exception.Message
} finally {
if (-not (Resolve-OwnedPointerBackup -BackupFile $backupFile -ReplaceSucceeded:$replaced)) {
# The pointer was written; a leftover backup is clutter, not failure.
if ($replaced) { Write-Warning "could not remove the replacement backup $backupFile" }
else { $backupResolved = $false }
}
if ($createdTemp -and (Test-Path -LiteralPath $tempFile)) {
Remove-Item -LiteralPath $tempFile -Force -ErrorAction SilentlyContinue
}
}
if (-not $backupResolved) {
Write-Error "Error: could not safely recover the active plan pointer backup."
exit 1
}
if ($failureMessage) {
Write-Error "Error: could not set the active plan pointer: $failureMessage"
exit 1
}
Write-Output "Active plan set to: $PlanId"
Write-Output "Path: $PlanDir"
Write-Output ""
@@ -8,6 +8,12 @@ State the analytical question or intended deliverable in one clear sentence.
[One sentence describing the analytical objective]
## Next Step
Record the single analytical action that should happen next. Update it whenever the active phase or immediate action changes.
[The single next analytical action. Update whenever phase status changes.]
## Current Phase
Name the phase currently being worked on.
@@ -76,6 +82,6 @@ Record each distinct error, the attempt number, and the resolution. Change the a
## Notes
- Update phase status as work progresses: `pending` to `in_progress` to `complete`.
- Re-read the goal and current phase before major analytical decisions.
- Re-read the goal, next step, and current phase before major analytical decisions.
- Log errors promptly so failed approaches are not repeated.
- Record query results and visual evidence in findings.md.
@@ -6,16 +6,19 @@
# attested hash, surfacing a "[PLAN TAMPERED]" warning instead.
#
# Resolution:
# 1. $PLAN_ID env var → ./.planning/$PLAN_ID/
# 2. ./.planning/.active_plan
# 3. Newest ./.planning/<dir>/ by mtime
# 4. Current directory when it is .planning/<valid-slug>/
# 5. Legacy ./task_plan.md at project root
# 1. --target root or <plan-id> → root or named plan under $PWF_PLAN_ROOT (default: cwd)
# 2. $PLAN_ID env var → ./.planning/$PLAN_ID/
# 3. ./.planning/.active_plan
# 4. Newest ./.planning/<dir>/ by mtime
# 5. Current directory when it is .planning/<valid-slug>/
# 6. Legacy ./task_plan.md at project root
#
# Usage:
# sh scripts/attest-plan.sh # attest the active plan
# sh scripts/attest-plan.sh --show # print the stored hash
# sh scripts/attest-plan.sh --clear # remove the attestation (re-open the plan)
# sh scripts/attest-plan.sh --target root # attest the legacy root plan
# sh scripts/attest-plan.sh --target <plan-id> # attest one named plan
set -u
@@ -42,6 +45,39 @@ resolve_from_slug_cwd() {
}
resolve_plan_file() {
if [ -n "${target:-}" ]; then
if [ "${target}" = "root" ]; then
target_root="."
if [ -n "${PWF_PLAN_ROOT:-}" ]; then
# Match the shared resolver's supported absolute local pins.
# A valid project-root junction is intentional, so resolve it.
case "${PWF_PLAN_ROOT}" in
\\\\*|//*|[A-Za-z]:[!\\/]*) return 1 ;;
/*|[A-Za-z]:[\\/]*) ;;
*) return 1 ;;
esac
target_root="$(cd "${PWF_PLAN_ROOT}" 2>/dev/null && pwd -P)" || return 1
fi
[ ! -L "${target_root}/task_plan.md" ] || return 1
[ -f "${target_root}/task_plan.md" ] || return 1
printf "%s\n" "${target_root}/task_plan.md"
return 0
fi
slug_is_valid "${target}" || return 1
# Reuse the binding, project-pin and containment rules. A flag replaces
# PLAN_ID for this call only; it does not change the shared pointer.
[ -f "${RESOLVER}" ] || return 1
target_dir="$(PLAN_ID="${target}" sh "${RESOLVER}" 2>/dev/null)" || return 1
[ -n "${target_dir}" ] || return 1
# Use the shell's physical path spelling: native Windows backslashes
# make sha256sum prefix its output with an escaped-filename marker.
target_dir="$(cd "${target_dir}" 2>/dev/null && pwd -P)" || return 1
[ ! -L "${target_dir}/task_plan.md" ] || return 1
[ -f "${target_dir}/task_plan.md" ] || return 1
printf "%s\n" "${target_dir}/task_plan.md"
return 0
fi
plan_dir=""
if [ -f "${RESOLVER}" ]; then
plan_dir="$(sh "${RESOLVER}" 2>/dev/null)"
@@ -80,7 +116,9 @@ resolve_plan_file() {
attestation_path_for() {
plan_file="$1"
plan_dir="$(dirname "${plan_file}")"
if [ "${plan_dir}" = "." ]; then
if [ "${target:-}" = "root" ]; then
printf "%s\n" "${plan_dir}/.plan-attestation"
elif [ "${plan_dir}" = "." ]; then
# Legacy mode: store at project root.
printf "%s\n" "./.plan-attestation"
else
@@ -101,14 +139,21 @@ compute_hash() {
}
mode="attest"
target=""
usage_error() {
printf "Usage: %s [--show|--clear|--target root|<plan-id>]\n" "$0" >&2
exit 2
}
case "${1:-}" in
--show) mode="show" ;;
--clear) mode="clear" ;;
"") mode="attest" ;;
*)
printf "Usage: %s [--show|--clear]\n" "$0" >&2
exit 2
--show) [ "$#" -eq 1 ] || usage_error; mode="show" ;;
--clear) [ "$#" -eq 1 ] || usage_error; mode="clear" ;;
--target)
[ "$#" -eq 2 ] || usage_error
[ -n "$2" ] || usage_error
target="$2"
;;
"") [ "$#" -eq 0 ] || usage_error ;;
*) usage_error ;;
esac
plan_file="$(resolve_plan_file)" || {
@@ -117,7 +162,9 @@ plan_file="$(resolve_plan_file)" || {
# a mistyped PLAN_ID attested a DIFFERENT plan at rc=0, and an operator
# who now sees a generic not-found is likely to go looking for the wrong
# problem. The selectors are bindings, so say which one refused.
if [ -n "${PLAN_ID:-}" ]; then
if [ -n "${target:-}" ]; then
printf "[plan-attest] --target %s did not resolve to a plan. An explicit target is a binding: nothing was attested and no other plan was substituted.\n" "${target}" >&2
elif [ -n "${PLAN_ID:-}" ]; then
printf "[plan-attest] PLAN_ID=%s names no plan directory under .planning. An explicit selector is a binding: nothing was attested and no other plan was substituted.\n" "${PLAN_ID}" >&2
elif [ -n "${PWF_PLAN_ROOT:-}" ]; then
printf "[plan-attest] PWF_PLAN_ROOT=%s did not resolve to a project root holding a plan. An explicit pin is a binding: nothing was attested and no other plan was substituted.\n" "${PWF_PLAN_ROOT}" >&2
@@ -156,6 +203,8 @@ case "${mode}" in
fi
;;
attest)
printf "Plan: %s\n" "${plan_file}"
printf "Attestation: %s\n" "${attestation_file}"
hash_val="$(compute_hash "${plan_file}")" || exit 1
# v2.40: protect the write with an advisory flock when available so
@@ -51,6 +51,8 @@ if ($PlanFile -ne "") {
}
if (-not (Test-Path $PlanFile)) {
# Automatic gate checks have nothing to report without a plan; the explicit report keeps its notice.
if ($Gate) { exit 0 }
Write-Host '[planning-with-files] No task_plan.md found -- no active planning session.'
exit 0
}
@@ -79,6 +79,9 @@ else
fi
if [ ! -f "$PLAN_FILE" ]; then
# Automatic gate checks have nothing to report without a plan (header:
# "plans stay silent in --gate mode"); the explicit report keeps its notice.
[ "$GATE" -eq 1 ] && exit 0
echo "[planning-with-files] No task_plan.md found — no active planning session."
exit 0
fi
@@ -18,6 +18,27 @@ param(
[switch]$Gated
)
# Windows PowerShell 5.1 can silently relocate a -File invocation when the
# inherited cwd contains wildcard characters such as [ or ]. Recover the
# physical cwd for a direct invocation before any project-relative paths are
# resolved.
if ($PSVersionTable.PSVersion.Major -eq 5 -and
[System.Management.Automation.WildcardPattern]::ContainsWildcardCharacters([Environment]::CurrentDirectory)) {
$processArgs = [Environment]::GetCommandLineArgs()
for ($index = 0; $index -lt ($processArgs.Length - 1); $index++) {
if ($processArgs[$index] -ieq '-File') {
$entryScript = $processArgs[$index + 1]
if (-not [IO.Path]::IsPathRooted($entryScript)) {
$entryScript = Join-Path ([Environment]::CurrentDirectory) $entryScript
}
if ([IO.Path]::GetFullPath($entryScript) -eq [IO.Path]::GetFullPath($PSCommandPath)) {
Set-Location -LiteralPath ([Environment]::CurrentDirectory)
}
break
}
}
}
$DATE = Get-Date -Format "yyyy-MM-dd"
# Resolve v3 opt-in mode. -Gated implies autonomous and is the stronger marker.
@@ -80,6 +101,26 @@ function Get-InheritedMode([string]$CurrentMode) {
return $CurrentMode
}
function Format-AttestationFailureReason {
param([object[]]$Output, [string]$Fallback)
$Reason = @(
$Output |
ForEach-Object {
if ($null -ne $_) { ($_.ToString()).Trim() }
} |
Where-Object { $_ }
) -join " "
$Reason = ($Reason -replace '\s+', ' ').Trim()
if ([string]::IsNullOrWhiteSpace($Reason)) {
return $Fallback
}
if ($Reason.Length -gt 300) {
return ($Reason.Substring(0, 297) + "...")
}
return $Reason
}
# Validate template
if ($Template -ne "default" -and $Template -ne "analytics") {
Write-Host "Unknown template: $Template (available: default, analytics). Using default."
@@ -136,20 +177,7 @@ if ($UsePlanDir) {
$Counter++
}
$TargetDir = Join-Path $PlanningRoot $PlanId
New-Item -ItemType Directory -Path $TargetDir -Force | Out-Null
# Reuse the selector's contained, atomic pointer replacement. Set-Content
# would follow a reparse point and truncate a hardlinked pointer in place,
# overwriting whichever file shares that inode.
$global:LASTEXITCODE = 0
try {
& $PlanSelector $PlanId *> $null
} catch {
$global:LASTEXITCODE = 1
}
if ($LASTEXITCODE -ne 0) {
Write-Error "Error: could not safely update the active plan pointer at $(Join-Path $PlanningRoot '.active_plan')."
exit 1
}
New-Item -ItemType Directory -Path $TargetDir -Force -ErrorAction Stop | Out-Null
$Mode = Get-InheritedMode $Mode
} else {
$TargetDir = (Get-Location).Path
@@ -173,11 +201,12 @@ if ($UsePlanDir) {
Write-Host "PLAN_ID=$PlanId"
}
try {
# Create task_plan.md if it doesn't exist
if (-not (Test-Path -LiteralPath $TaskPlanPath)) {
$AnalyticsPlan = Join-Path $TemplateDir "analytics_task_plan.md"
if ($Template -eq "analytics" -and (Test-Path $AnalyticsPlan)) {
Copy-Item -LiteralPath $AnalyticsPlan -Destination $TaskPlanPath
Copy-Item -LiteralPath $AnalyticsPlan -Destination $TaskPlanPath -ErrorAction Stop
} else {
@"
# Task Plan: [Brief Description]
@@ -226,7 +255,7 @@ Phase 1
## Errors Encountered
| Error | Resolution |
|-------|------------|
"@ | Out-File -LiteralPath $TaskPlanPath -Encoding UTF8
"@ | Out-File -LiteralPath $TaskPlanPath -Encoding UTF8 -ErrorAction Stop
}
Write-Host "Created $TaskPlanDisplay"
} else {
@@ -237,7 +266,7 @@ Phase 1
if (-not (Test-Path -LiteralPath $FindingsPath)) {
$AnalyticsFindings = Join-Path $TemplateDir "analytics_findings.md"
if ($Template -eq "analytics" -and (Test-Path $AnalyticsFindings)) {
Copy-Item -LiteralPath $AnalyticsFindings -Destination $FindingsPath
Copy-Item -LiteralPath $AnalyticsFindings -Destination $FindingsPath -ErrorAction Stop
} else {
@"
# Findings & Decisions
@@ -258,7 +287,7 @@ if (-not (Test-Path -LiteralPath $FindingsPath)) {
## Resources
-
"@ | Out-File -LiteralPath $FindingsPath -Encoding UTF8
"@ | Out-File -LiteralPath $FindingsPath -Encoding UTF8 -ErrorAction Stop
}
Write-Host "Created $FindingsDisplay"
} else {
@@ -287,7 +316,7 @@ if (-not (Test-Path -LiteralPath $ProgressPath)) {
### Errors
| Error | Resolution |
|-------|------------|
"@ | Out-File -LiteralPath $ProgressPath -Encoding UTF8
"@ | Out-File -LiteralPath $ProgressPath -Encoding UTF8 -ErrorAction Stop
} else {
@"
# Progress Log
@@ -308,12 +337,58 @@ if (-not (Test-Path -LiteralPath $ProgressPath)) {
### Errors
| Error | Resolution |
|-------|------------|
"@ | Out-File -LiteralPath $ProgressPath -Encoding UTF8
"@ | Out-File -LiteralPath $ProgressPath -Encoding UTF8 -ErrorAction Stop
}
Write-Host "Created $ProgressDisplay"
} else {
Write-Host "$ProgressDisplay already exists, skipping"
}
} catch {
Write-Error "Error: could not initialize planning files in '$TargetDir': $($_.Exception.Message)"
exit 1
}
if ($UsePlanDir) {
# Activate the named plan only after all three planning files are ready.
# This matches init-session.sh and prevents a failed initialization from
# leaving .active_plan pointed at a partial plan directory.
$pointerSet = $false
for ($attempt = 1; $attempt -le 5; $attempt++) {
$global:LASTEXITCODE = 0
try {
$selectorResult = & $PlanSelector $PlanId 2>&1
} catch {
$selectorResult = $_
$global:LASTEXITCODE = 1
}
if ($LASTEXITCODE -eq 0) {
$pointerSet = $true
break
}
# Another writer can replace the pointer between the selector's
# Get-Item and final-path check. Retry only that transient result.
$transientPointerRace = $selectorResult -is [System.Management.Automation.ErrorRecord] -and
$selectorResult.Exception.Message -in @(
'Error: could not set the active plan pointer: the active plan pointer became unsafe during replacement',
'Error: the active plan pointer must be a regular file within the project.'
)
if ($attempt -eq 5 -or -not $transientPointerRace) {
break
}
Start-Sleep -Milliseconds 50
$global:LASTEXITCODE = 0
try {
& $PlanSelector -VerifyRoot *> $null
} catch {
$global:LASTEXITCODE = 1
}
if ($LASTEXITCODE -ne 0) { break }
}
if (-not $pointerSet) {
Write-Error "Error: could not safely update the active plan pointer at $(Join-Path $PlanningRoot '.active_plan')."
exit 1
}
}
Write-Host ""
Write-Host "Planning files initialized!"
@@ -355,8 +430,11 @@ if ($Mode -ne "") {
# to another project or plan (#261, #237). Root mode clears both instead:
# the attester only falls back to the legacy ./task_plan.md when no
# selector is set, and a bound pin would make it refuse the root plan.
$AttestationSucceeded = $false
$AttestationCommand = "attest-plan"
$AttestationReason = "task_plan.md was not available for attestation"
$PlanFilePwf = Join-Path $PlanDirPwf "task_plan.md"
if (Test-Path -LiteralPath $PlanFilePwf) {
if (Test-Path -LiteralPath $PlanFilePwf -PathType Leaf) {
$HadPlanId = Test-Path Env:PLAN_ID
$PreviousPlanId = $env:PLAN_ID
$HadPlanRoot = Test-Path Env:PWF_PLAN_ROOT
@@ -370,21 +448,54 @@ if ($Mode -ne "") {
Remove-Item Env:PLAN_ID -ErrorAction SilentlyContinue
}
# A called script can return without changing $LASTEXITCODE, so
# clear the inherited value before every attempt. Both a non-zero
# status and a terminating exception mean the plan is not attested.
$global:LASTEXITCODE = 0
$AttestOutput = @()
$IsWindowsHost = [Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT
if ($IsWindowsHost) {
$AttestPs1 = Join-Path $ScriptDir "attest-plan.ps1"
if (Test-Path -LiteralPath $AttestPs1) {
& $AttestPs1 *> $null
$AttestationCommand = "attest-plan.ps1"
$AttestPs1 = Join-Path $ScriptDir $AttestationCommand
if (Test-Path -LiteralPath $AttestPs1 -PathType Leaf) {
$AttestOutput = @(& $AttestPs1 2>&1)
$AttestExitCode = $LASTEXITCODE
if ($AttestExitCode -eq 0) {
$AttestationSucceeded = $true
$AttestationReason = ""
} else {
$AttestationReason = Format-AttestationFailureReason `
-Output $AttestOutput `
-Fallback "$AttestationCommand exited with code $AttestExitCode"
}
} else {
$AttestationReason = "$AttestationCommand was not found beside init-session.ps1"
}
} else {
$AttestSh = Join-Path $ScriptDir "attest-plan.sh"
$AttestationCommand = "attest-plan.sh"
$AttestSh = Join-Path $ScriptDir $AttestationCommand
$Sh = Get-Command sh -ErrorAction SilentlyContinue
if ($Sh -and (Test-Path -LiteralPath $AttestSh)) {
& $Sh.Path $AttestSh *> $null
if ($Sh -and (Test-Path -LiteralPath $AttestSh -PathType Leaf)) {
$AttestOutput = @(& $Sh.Path $AttestSh 2>&1)
$AttestExitCode = $LASTEXITCODE
if ($AttestExitCode -eq 0) {
$AttestationSucceeded = $true
$AttestationReason = ""
} else {
$AttestationReason = Format-AttestationFailureReason `
-Output $AttestOutput `
-Fallback "$AttestationCommand exited with code $AttestExitCode"
}
} elseif (-not $Sh) {
$AttestationReason = "sh was not found; $AttestationCommand could not run"
} else {
$AttestationReason = "$AttestationCommand was not found beside init-session.ps1"
}
}
} catch {
# attestation failure must not abort init; the mode marker still stands.
$AttestationReason = Format-AttestationFailureReason `
-Output @($_) `
-Fallback "$AttestationCommand failed"
} finally {
if ($HadPlanId) {
$env:PLAN_ID = $PreviousPlanId
@@ -399,5 +510,9 @@ if ($Mode -ne "") {
}
}
Write-Host "Mode: $MarkerText (attested, gate counter reset)"
if ($AttestationSucceeded) {
Write-Host "Mode: $MarkerText (attested, gate counter reset)"
} else {
Write-Host "Mode: $MarkerText (NOT attested: $AttestationReason; run $AttestationCommand before the first hook fire)"
}
}
@@ -186,6 +186,10 @@ apply_v3_mode() {
_mode_plan="$2"
[ -z "$MODE" ] && return 0
ATTESTATION_OK=0
ATTESTATION_COMMAND="attest-plan.sh"
ATTESTATION_REASON="task_plan.md was not available for attestation"
# (a) reset the gate block counter and drop any stale gate ledger so a prior
# run's high block count cannot let the next run stop instantly.
printf '0\n' > "${_mode_dir}/.stop_blocks"
@@ -210,13 +214,54 @@ apply_v3_mode() {
# ./task_plan.md when no selector is set, and a bound pin would make it
# refuse the root plan. Run from the project root (CWD here) so both
# resolutions land.
_attest="${SCRIPT_DIR}/attest-plan.sh"
if [ -f "${_attest}" ] && [ -f "${_mode_plan}" ]; then
if [ "$SLUG_MODE" -eq 1 ]; then
PWF_PLAN_ROOT="$PWD" PLAN_ID="${PLAN_ID}" sh "${_attest}" >/dev/null 2>&1 || true
_attest="${SCRIPT_DIR}/${ATTESTATION_COMMAND}"
if [ ! -f "${_attest}" ]; then
ATTESTATION_REASON="${ATTESTATION_COMMAND} was not found beside init-session.sh"
return 0
fi
if [ ! -f "${_mode_plan}" ]; then
return 0
fi
if [ "$SLUG_MODE" -eq 1 ]; then
if _attest_output="$(PWF_PLAN_ROOT="$PWD" PLAN_ID="${PLAN_ID}" sh "${_attest}" 2>&1)"; then
ATTESTATION_OK=1
ATTESTATION_REASON=""
return 0
else
PWF_PLAN_ROOT="" PLAN_ID="" sh "${_attest}" >/dev/null 2>&1 || true
_attest_rc=$?
fi
else
if _attest_output="$(PWF_PLAN_ROOT="" PLAN_ID="" sh "${_attest}" 2>&1)"; then
ATTESTATION_OK=1
ATTESTATION_REASON=""
return 0
else
_attest_rc=$?
fi
fi
_attest_reason="$(
printf '%s\n' "${_attest_output}" |
sed -n '/[^[:space:]]/ { s/[[:space:]][[:space:]]*/ /g; s/^ //; s/ $//; p; q; }' |
cut -c1-300
)"
if [ -n "${_attest_reason}" ]; then
ATTESTATION_REASON="${_attest_reason}"
else
ATTESTATION_REASON="${ATTESTATION_COMMAND} exited with code ${_attest_rc}"
fi
return 0
}
print_v3_mode_status() {
_status_dir="$1"
_marker="$(cat "${_status_dir}/.mode")"
if [ "${ATTESTATION_OK:-0}" -eq 1 ]; then
printf 'Mode: %s (attested, gate counter reset)\n' "${_marker}"
else
printf 'Mode: %s (NOT attested: %s; run %s before the first hook fire)\n' \
"${_marker}" "${ATTESTATION_REASON:-attestation failed}" "${ATTESTATION_COMMAND:-attest-plan.sh}"
fi
}
@@ -431,7 +476,7 @@ if [ "$SLUG_MODE" -eq 1 ]; then
echo "Pin this terminal to the plan for parallel sessions:"
echo " export PLAN_ID=$PLAN_ID"
if [ -n "$MODE" ]; then
echo "Mode: $(cat "${PLAN_DIR}/.mode") (attested, gate counter reset)"
print_v3_mode_status "${PLAN_DIR}"
fi
else
PROJECT_NAME="${PROJECT_NAME:-project}"
@@ -442,6 +487,6 @@ else
echo "Planning files initialized!"
echo "Files: task_plan.md, findings.md, progress.md"
if [ -n "$MODE" ]; then
echo "Mode: $(cat "$(pwd)/.mode") (attested, gate counter reset)"
print_v3_mode_status "$(pwd)"
fi
fi
@@ -468,10 +468,13 @@ def _opencode_state_annotation(state: Any) -> str:
def _format_opencode_part(data: Dict[str, Any], session_id: str) -> Optional[Dict[str, Any]]:
"""Print-ready summary for one OpenCode part row."""
if not isinstance(data, dict):
return None
ptype = data.get('type')
short = safe_session_label(session_id)
if ptype == 'tool':
tool = (data.get('tool') or '').lower()
tool_value = data.get('tool')
tool = tool_value.lower() if isinstance(tool_value, str) else ''
state = data.get('state') or {}
input_ = state.get('input') if isinstance(state, dict) else None
input_ = input_ if isinstance(input_, dict) else {}
@@ -486,7 +489,8 @@ def _format_opencode_part(data: Dict[str, Any], session_id: str) -> Optional[Dic
return {'session': short, 'summary': f"Tool bash: {cmd}{outcome}"}
return {'session': short, 'summary': f"Tool {tool}{outcome}"}
if ptype == 'text':
text = (data.get('text') or '')[:300]
text_value = data.get('text')
text = text_value[:300] if isinstance(text_value, str) else ''
if text.strip():
return {'session': short, 'summary': f"text: {text}"}
return None
@@ -128,9 +128,58 @@ function Test-SafeActiveFile {
$linked = ([string]$item.LinkType) -in @('SymbolicLink', 'Junction')
return -not $item.PSIsContainer -and
($AllowLink -or -not $linked) -and
($AllowLink -or -not $item.IsReadOnly) -and
(Test-WithinRoot $ActiveFile)
}
function Test-SafeOwnedBackupFile {
param([string]$Path)
$item = Get-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue
if (-not $item) { return $false }
$linked = ([string]$item.LinkType) -in @('SymbolicLink', 'Junction')
return -not $item.PSIsContainer -and -not $linked -and (Test-WithinRoot $Path)
}
function Resolve-OwnedPointerBackup {
param([string]$BackupFile, [switch]$ReplaceSucceeded)
# ReplaceFile can move the old destination before failing to move the new
# file. A caller-named backup makes that intermediate state attributable to
# this invocation, so recover or delete only this GUID path. Never glob for
# ~RF*.TMP: a concurrent selector may own those files.
$maxAttempts = 20
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
$backupItem = Get-Item -LiteralPath $BackupFile -Force -ErrorAction SilentlyContinue
if (-not $backupItem) { return $true }
# A failed inspection is transient under concurrent writers: retry.
if (-not (Test-SafeOwnedBackupFile $BackupFile)) { Start-Sleep -Milliseconds 25; continue }
$currentActive = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
# After our own successful replacement the backup holds a superseded
# value: only delete it, never move it back over a newer pointer.
if ($ReplaceSucceeded -or $currentActive) {
if (-not $ReplaceSucceeded -and -not (Test-SafeActiveFile)) { Start-Sleep -Milliseconds 25; continue }
try {
[IO.File]::Delete($BackupFile)
return $true
} catch {
if ($attempt -eq $maxAttempts) { return $false }
}
} else {
try {
[IO.File]::Move($BackupFile, $ActiveFile)
return $true
} catch {
# Another selector may have recreated the pointer between the
# absence check and Move. Reinspect before deciding whether the
# owned backup is now redundant.
if ($attempt -eq $maxAttempts) { return $false }
}
}
Start-Sleep -Milliseconds 25
}
return $false
}
function Get-CurrentActivePlan {
if (-not (Test-SafeActiveFile -AllowLink)) { return "" }
try {
@@ -305,9 +354,16 @@ if ($activeItem -and -not (Test-SafeActiveFile)) {
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
# Replace the directory entry instead of truncating an existing inode: a
# hardlinked pointer must not overwrite another file. The exclusive temporary
# file lives beside the pointer, keeping replacement on the same filesystem.
$tempFile = Join-Path $PlanRoot ('.active_plan.' + [guid]::NewGuid().ToString('N') + '.tmp')
# file and caller-owned backup live beside the pointer, keeping replacement on
# the same filesystem. Naming the backup prevents ReplaceFile from inventing
# an unowned ~RF*.TMP path if its final rename fails.
$operationId = [guid]::NewGuid().ToString('N')
$tempFile = Join-Path $PlanRoot ('.active_plan.' + $operationId + '.tmp')
$backupFile = Join-Path $PlanRoot ('.active_plan.' + $operationId + '.replace-backup')
$createdTemp = $false
$failureMessage = ""
$backupResolved = $true
$replaced = $false
try {
$stream = [IO.File]::Open($tempFile, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
$createdTemp = $true
@@ -316,19 +372,48 @@ try {
$stream.Write($bytes, 0, $bytes.Length)
$stream.Flush()
} finally { $stream.Dispose() }
if ($activeItem) {
[IO.File]::Replace($tempFile, $ActiveFile, [NullString]::Value)
} else {
[IO.File]::Move($tempFile, $ActiveFile)
$maxAttempts = 20
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
try {
$currentActive = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
if ($currentActive) {
if (-not (Test-SafeActiveFile)) {
throw [InvalidOperationException]::new("the active plan pointer became unsafe during replacement")
}
[IO.File]::Replace($tempFile, $ActiveFile, $backupFile)
} else {
[IO.File]::Move($tempFile, $ActiveFile)
}
$replaced = $true
break
} catch [IO.IOException] {
if (-not (Resolve-OwnedPointerBackup -BackupFile $backupFile)) {
throw [IO.IOException]::new("could not safely recover the active plan pointer after replacement failure")
}
if ($attempt -eq $maxAttempts) { throw }
Start-Sleep -Milliseconds 25
}
}
} catch {
Write-Error "Error: could not set the active plan pointer: $($_.Exception.Message)"
exit 1
$failureMessage = $_.Exception.Message
} finally {
if (-not (Resolve-OwnedPointerBackup -BackupFile $backupFile -ReplaceSucceeded:$replaced)) {
# The pointer was written; a leftover backup is clutter, not failure.
if ($replaced) { Write-Warning "could not remove the replacement backup $backupFile" }
else { $backupResolved = $false }
}
if ($createdTemp -and (Test-Path -LiteralPath $tempFile)) {
Remove-Item -LiteralPath $tempFile -Force -ErrorAction SilentlyContinue
}
}
if (-not $backupResolved) {
Write-Error "Error: could not safely recover the active plan pointer backup."
exit 1
}
if ($failureMessage) {
Write-Error "Error: could not set the active plan pointer: $failureMessage"
exit 1
}
Write-Output "Active plan set to: $PlanId"
Write-Output "Path: $PlanDir"
Write-Output ""
+2 -2
View File
@@ -1,9 +1,9 @@
{
"version": 1,
"hooks": {
"userPromptSubmit": [
"sessionStart": [
{
"command": ".cursor/hooks/user-prompt-submit.sh",
"command": ".cursor/hooks/session-start.sh",
"timeout": 5
}
],
+5 -5
View File
@@ -1,29 +1,29 @@
{
"version": 1,
"hooks": {
"userPromptSubmit": [
"sessionStart": [
{
"command": "powershell -ExecutionPolicy Bypass -File .cursor/hooks/user-prompt-submit.ps1",
"command": "powershell -NoProfile -ExecutionPolicy Bypass -File .cursor/hooks/session-start.ps1",
"timeout": 5
}
],
"preToolUse": [
{
"command": "powershell -ExecutionPolicy Bypass -File .cursor/hooks/pre-tool-use.ps1",
"command": "powershell -NoProfile -ExecutionPolicy Bypass -File .cursor/hooks/pre-tool-use.ps1",
"matcher": "Write|Edit|Shell|Read",
"timeout": 5
}
],
"postToolUse": [
{
"command": "powershell -ExecutionPolicy Bypass -File .cursor/hooks/post-tool-use.ps1",
"command": "powershell -NoProfile -ExecutionPolicy Bypass -File .cursor/hooks/post-tool-use.ps1",
"matcher": "Write|Edit",
"timeout": 5
}
],
"stop": [
{
"command": "powershell -ExecutionPolicy Bypass -File .cursor/hooks/stop.ps1",
"command": "powershell -NoProfile -ExecutionPolicy Bypass -File .cursor/hooks/stop.ps1",
"timeout": 10,
"loop_limit": 3
}
+23 -11
View File
@@ -1,24 +1,36 @@
# planning-with-files: Post-tool-use hook for Cursor (PowerShell)
# Reminds the agent to update task_plan.md after file modifications.
# Injects a reminder after file modifications using postToolUse's context field.
# Issue #195 opt-out. The disabled branch reproduces this hook's own
# no-plan-file behaviour, so the Cursor protocol shape never changes.
if ($env:PLANNING_DISABLED -eq '1') { exit 0 }
if ($env:PLANNING_DISABLED -eq '1') {
Write-Output '{}'
exit 0
}
# The OEM code page turns the em-dash into "-" and non-ASCII plan text into "?"
# on both Windows PowerShell 5.1 and pwsh; the plan reaches Cursor as UTF-8.
# ConstrainedLanguage may refuse the assignment, which only keeps the old bytes.
try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch { }
. (Join-Path $PSScriptRoot "resolve-plan-context.ps1")
$PlanContext = Resolve-CursorPlanContext
$PlanFile = if ($PlanContext.Directory) {
Join-Path $PlanContext.Directory "task_plan.md"
} else {
$null
}
try {
. (Join-Path $PSScriptRoot "resolve-plan-context.ps1")
$PlanContext = Resolve-CursorPlanContext
$PlanFile = if ($PlanContext.Directory) {
Join-Path $PlanContext.Directory "task_plan.md"
} else {
$null
}
if ($PlanFile -and (Test-Path -LiteralPath $PlanFile -PathType Leaf)) {
Write-Output "[planning-with-files] Update progress.md with what you just did. If a phase is now complete, update task_plan.md status."
if ($PlanFile -and (Test-Path -LiteralPath $PlanFile -PathType Leaf)) {
$response = @{
additional_context = "[planning-with-files] Update progress.md with what you just did. If a phase is now complete, update task_plan.md status."
}
Write-Output ($response | ConvertTo-Json -Compress)
} else {
Write-Output '{}'
}
} catch {
Write-Output '{}'
}
exit 0
Regular → Executable
+5 -3
View File
@@ -1,12 +1,14 @@
#!/bin/bash
# planning-with-files: Post-tool-use hook for Cursor
# Reminds the agent to update task_plan.md after file modifications.
# Injects a reminder after file modifications using postToolUse's context field.
# Issue #195 opt-out. The disabled branch reproduces this hook's own
# no-plan-file behaviour, so the Cursor protocol shape never changes.
[ "${PLANNING_DISABLED:-}" = "1" ] && exit 0
[ "${PLANNING_DISABLED:-}" = "1" ] && { echo '{}'; exit 0; }
if [ -f task_plan.md ]; then
echo "[planning-with-files] Update progress.md with what you just did. If a phase is now complete, update task_plan.md status."
echo '{"additional_context":"[planning-with-files] Update progress.md with what you just did. If a phase is now complete, update task_plan.md status."}'
else
echo '{}'
fi
exit 0
+4 -35
View File
@@ -1,37 +1,6 @@
# planning-with-files: Pre-tool-use hook for Cursor (PowerShell)
# Reads the first 30 lines of task_plan.md to keep goals in context.
# Returns {"decision": "allow"} — this hook never blocks tools.
# planning-with-files: Pre-tool-use hook for Cursor (PowerShell)
# Cursor preToolUse is a permission hook. Plan context is injected by sessionStart.
# Always allow tools; this hook never blocks them.
# Issue #195 opt-out. The disabled branch reproduces this hook's own
# no-plan-file behaviour, so the Cursor protocol shape never changes.
if ($env:PLANNING_DISABLED -eq '1') {
Write-Output '{"decision": "allow"}'
exit 0
}
# The OEM code page turns the em-dash into "-" and non-ASCII plan text into "?"
# on both Windows PowerShell 5.1 and pwsh; the plan reaches Cursor as UTF-8.
# ConstrainedLanguage may refuse the assignment, which only keeps the old bytes.
try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch { }
# The protocol response is printed from finally: a missing helper or a
# profile that sets $ErrorActionPreference = 'Stop' must not end the hook
# without it. This hook never blocks a tool.
try {
. (Join-Path $PSScriptRoot "resolve-plan-context.ps1")
$PlanContext = Resolve-CursorPlanContext
$PlanFile = if ($PlanContext.Directory) {
Join-Path $PlanContext.Directory "task_plan.md"
} else {
$null
}
if ($PlanFile -and (Test-Path -LiteralPath $PlanFile -PathType Leaf)) {
Get-Content -LiteralPath $PlanFile -TotalCount 30 | Write-Host
}
} catch {
# A planning error is never a reason to hold a tool.
} finally {
Write-Output '{"decision": "allow"}'
}
Write-Output '{"permission":"allow"}'
exit 0
Regular → Executable
+3 -14
View File
@@ -1,18 +1,7 @@
#!/bin/bash
# planning-with-files: Pre-tool-use hook for Cursor
# Reads the first 30 lines of task_plan.md to keep goals in context.
# Returns {"decision": "allow"} — this hook never blocks tools.
# Cursor preToolUse is a permission hook. Plan context is injected by sessionStart.
# Always allow tools; this hook never blocks them.
# Issue #195 opt-out. The disabled branch reproduces this hook's own
# no-plan-file behaviour, so the Cursor protocol shape never changes.
[ "${PLANNING_DISABLED:-}" = "1" ] && { echo '{"decision": "allow"}'; exit 0; }
PLAN_FILE="task_plan.md"
if [ -f "$PLAN_FILE" ]; then
# Log plan context to stderr (visible in Cursor's hook logs)
head -30 "$PLAN_FILE" >&2
fi
echo '{"decision": "allow"}'
echo '{"permission":"allow"}'
exit 0
+18
View File
@@ -0,0 +1,18 @@
# Inject the selected plan as initial session context using Cursor's sessionStart schema.
if ($env:PLANNING_DISABLED -eq '1') {
Write-Output '{}'
exit 0
}
try {
$context = & (Join-Path $PSScriptRoot 'user-prompt-submit.ps1') | Out-String
if ([string]::IsNullOrWhiteSpace($context)) {
Write-Output '{}'
exit 0
}
$response = @{ additional_context = $context.TrimEnd() }
Write-Output ($response | ConvertTo-Json -Compress)
} catch {
Write-Output '{}'
}
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env bash
# Inject the selected plan as initial session context using Cursor's sessionStart schema.
if [ "${PLANNING_DISABLED:-}" = "1" ]; then
echo '{}'
exit 0
fi
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
CONTEXT="$(sh "${SCRIPT_DIR}/user-prompt-submit.sh")"
if [ -z "${CONTEXT}" ]; then
echo '{}'
exit 0
fi
# JSON-escape the context with the first interpreter that actually runs:
# python3 can be a stub (the Windows Store alias), so python is tried next.
# -I keeps a json.py in the project directory from being imported.
RESPONSE=""
for PYTHON in "$(command -v python3 2>/dev/null)" "$(command -v python 2>/dev/null)"; do
[ -n "${PYTHON}" ] || continue
RESPONSE="$(printf '%s' "${CONTEXT}" | "$PYTHON" -I -X utf8 -c 'import json,sys; print(json.dumps({"additional_context": sys.stdin.buffer.read().decode("utf-8", "replace")}))' 2>/dev/null)" || RESPONSE=""
[ -n "${RESPONSE}" ] && break
done
# Keep the response valid when no Python runs; planning scripts require
# Python for their normal context-injection path as well.
if [ -n "${RESPONSE}" ]; then
printf '%s\n' "${RESPONSE}"
else
echo '{}'
fi
exit 0
+2 -1
View File
@@ -48,7 +48,8 @@ if ($TOTAL -eq 0) {
}
if ($COMPLETE -eq $TOTAL -and $TOTAL -gt 0) {
Write-Host "{`"followup_message`": `"[planning-with-files] ALL PHASES COMPLETE ($COMPLETE/$TOTAL). If the user has additional work, add new phases to task_plan.md before starting.`"}"
# All phases complete: stay silent. Cursor submits a followup_message as
# the next user message, so a finished plan must not produce one.
exit 0
} else {
Write-Host "{`"followup_message`": `"[planning-with-files] Task incomplete ($COMPLETE/$TOTAL phases done). Update progress.md, then read task_plan.md and continue working on the remaining phases.`"}"
Regular → Executable
+2 -2
View File
@@ -43,8 +43,8 @@ if [ "$TOTAL" -eq 0 ]; then
fi
if [ "$COMPLETE" -eq "$TOTAL" ] && [ "$TOTAL" -gt 0 ]; then
# All phases complete — provide re-entry guidance
echo "{\"followup_message\": \"[planning-with-files] ALL PHASES COMPLETE ($COMPLETE/$TOTAL). If the user has additional work, add new phases to task_plan.md before starting.\"}"
# All phases complete: stay silent. Cursor submits a followup_message as
# the next user message, so a finished plan must not produce one.
exit 0
else
# Phases incomplete — auto-continue via followup_message
+4 -4
View File
@@ -4,7 +4,7 @@
# Issue #195 opt-out. The disabled branch reproduces this hook's own
# no-plan-file behaviour, so the Cursor protocol shape never changes.
if ($env:PLANNING_DISABLED -eq '1') { exit 0 }
if ($env:PLANNING_DISABLED -eq '1') { return }
# The OEM code page turns the em-dash into "-" and non-ASCII plan text into "?"
# on both Windows PowerShell 5.1 and pwsh; the plan reaches Cursor as UTF-8.
@@ -33,7 +33,7 @@ if (-not $PlanContext.Directory) {
Write-Output "[planning-with-files] The selected plan could not be resolved safely ($($PlanContext.Detail)). Check PLAN_ID, PWF_PLAN_ROOT, and .active_plan; nothing injected."
}
}
exit 0
return
}
$planFile = Join-Path $PlanContext.Directory "task_plan.md"
@@ -69,7 +69,7 @@ if (Test-Path -LiteralPath $planFile -PathType Leaf) {
if ($nestedRoots.Count -gt 0) {
$nestedList = (@($nestedRoots | Select-Object -First 3)) -join ", "
Write-Output "[planning-with-files] Ambiguous plan: this cwd has an active plan and a nested project below it has its own ($nestedList). Nothing injected. Pin the thread with PWF_PLAN_ROOT=<absolute path> or PLAN_ID=<slug>."
exit 0
return
}
}
@@ -90,4 +90,4 @@ if (Test-Path -LiteralPath $planFile -PathType Leaf) {
Write-Output ""
Write-Output "[planning-with-files] Read findings.md for research context. Continue from the current phase."
}
exit 0
return
View File
+1 -1
View File
@@ -36,7 +36,7 @@ hooks:
- type: command
command: "SH=\"\"; for c in \"${PWF_SCRIPT_DIR}/skill-hook.sh\" \"${CLAUDE_SKILL_DIR}/scripts/skill-hook.sh\" \"$HOME/.cursor/skills/planning-with-files/scripts/skill-hook.sh\" \"$HOME/.claude/skills/planning-with-files/scripts/skill-hook.sh\" \"$HOME/.claude/plugins/marketplaces/planning-with-files/scripts/skill-hook.sh\"; do [ -f \"$c\" ] && { SH=\"$c\"; break; }; done; [ -n \"$SH\" ] && sh \"$SH\" --event=precompact; exit 0"
metadata:
version: "3.20.4"
version: "3.21.0"
---
@@ -6,16 +6,19 @@
# attested hash, surfacing a "[PLAN TAMPERED]" warning instead.
#
# Resolution:
# 1. $PLAN_ID env var → ./.planning/$PLAN_ID/
# 2. ./.planning/.active_plan
# 3. Newest ./.planning/<dir>/ by mtime
# 4. Current directory when it is .planning/<valid-slug>/
# 5. Legacy ./task_plan.md at project root
# 1. --target root or <plan-id> → root or named plan under $PWF_PLAN_ROOT (default: cwd)
# 2. $PLAN_ID env var → ./.planning/$PLAN_ID/
# 3. ./.planning/.active_plan
# 4. Newest ./.planning/<dir>/ by mtime
# 5. Current directory when it is .planning/<valid-slug>/
# 6. Legacy ./task_plan.md at project root
#
# Usage:
# sh scripts/attest-plan.sh # attest the active plan
# sh scripts/attest-plan.sh --show # print the stored hash
# sh scripts/attest-plan.sh --clear # remove the attestation (re-open the plan)
# sh scripts/attest-plan.sh --target root # attest the legacy root plan
# sh scripts/attest-plan.sh --target <plan-id> # attest one named plan
set -u
@@ -42,6 +45,39 @@ resolve_from_slug_cwd() {
}
resolve_plan_file() {
if [ -n "${target:-}" ]; then
if [ "${target}" = "root" ]; then
target_root="."
if [ -n "${PWF_PLAN_ROOT:-}" ]; then
# Match the shared resolver's supported absolute local pins.
# A valid project-root junction is intentional, so resolve it.
case "${PWF_PLAN_ROOT}" in
\\\\*|//*|[A-Za-z]:[!\\/]*) return 1 ;;
/*|[A-Za-z]:[\\/]*) ;;
*) return 1 ;;
esac
target_root="$(cd "${PWF_PLAN_ROOT}" 2>/dev/null && pwd -P)" || return 1
fi
[ ! -L "${target_root}/task_plan.md" ] || return 1
[ -f "${target_root}/task_plan.md" ] || return 1
printf "%s\n" "${target_root}/task_plan.md"
return 0
fi
slug_is_valid "${target}" || return 1
# Reuse the binding, project-pin and containment rules. A flag replaces
# PLAN_ID for this call only; it does not change the shared pointer.
[ -f "${RESOLVER}" ] || return 1
target_dir="$(PLAN_ID="${target}" sh "${RESOLVER}" 2>/dev/null)" || return 1
[ -n "${target_dir}" ] || return 1
# Use the shell's physical path spelling: native Windows backslashes
# make sha256sum prefix its output with an escaped-filename marker.
target_dir="$(cd "${target_dir}" 2>/dev/null && pwd -P)" || return 1
[ ! -L "${target_dir}/task_plan.md" ] || return 1
[ -f "${target_dir}/task_plan.md" ] || return 1
printf "%s\n" "${target_dir}/task_plan.md"
return 0
fi
plan_dir=""
if [ -f "${RESOLVER}" ]; then
plan_dir="$(sh "${RESOLVER}" 2>/dev/null)"
@@ -80,7 +116,9 @@ resolve_plan_file() {
attestation_path_for() {
plan_file="$1"
plan_dir="$(dirname "${plan_file}")"
if [ "${plan_dir}" = "." ]; then
if [ "${target:-}" = "root" ]; then
printf "%s\n" "${plan_dir}/.plan-attestation"
elif [ "${plan_dir}" = "." ]; then
# Legacy mode: store at project root.
printf "%s\n" "./.plan-attestation"
else
@@ -101,14 +139,21 @@ compute_hash() {
}
mode="attest"
target=""
usage_error() {
printf "Usage: %s [--show|--clear|--target root|<plan-id>]\n" "$0" >&2
exit 2
}
case "${1:-}" in
--show) mode="show" ;;
--clear) mode="clear" ;;
"") mode="attest" ;;
*)
printf "Usage: %s [--show|--clear]\n" "$0" >&2
exit 2
--show) [ "$#" -eq 1 ] || usage_error; mode="show" ;;
--clear) [ "$#" -eq 1 ] || usage_error; mode="clear" ;;
--target)
[ "$#" -eq 2 ] || usage_error
[ -n "$2" ] || usage_error
target="$2"
;;
"") [ "$#" -eq 0 ] || usage_error ;;
*) usage_error ;;
esac
plan_file="$(resolve_plan_file)" || {
@@ -117,7 +162,9 @@ plan_file="$(resolve_plan_file)" || {
# a mistyped PLAN_ID attested a DIFFERENT plan at rc=0, and an operator
# who now sees a generic not-found is likely to go looking for the wrong
# problem. The selectors are bindings, so say which one refused.
if [ -n "${PLAN_ID:-}" ]; then
if [ -n "${target:-}" ]; then
printf "[plan-attest] --target %s did not resolve to a plan. An explicit target is a binding: nothing was attested and no other plan was substituted.\n" "${target}" >&2
elif [ -n "${PLAN_ID:-}" ]; then
printf "[plan-attest] PLAN_ID=%s names no plan directory under .planning. An explicit selector is a binding: nothing was attested and no other plan was substituted.\n" "${PLAN_ID}" >&2
elif [ -n "${PWF_PLAN_ROOT:-}" ]; then
printf "[plan-attest] PWF_PLAN_ROOT=%s did not resolve to a project root holding a plan. An explicit pin is a binding: nothing was attested and no other plan was substituted.\n" "${PWF_PLAN_ROOT}" >&2
@@ -156,6 +203,8 @@ case "${mode}" in
fi
;;
attest)
printf "Plan: %s\n" "${plan_file}"
printf "Attestation: %s\n" "${attestation_file}"
hash_val="$(compute_hash "${plan_file}")" || exit 1
# v2.40: protect the write with an advisory flock when available so
@@ -51,6 +51,8 @@ if ($PlanFile -ne "") {
}
if (-not (Test-Path $PlanFile)) {
# Automatic gate checks have nothing to report without a plan; the explicit report keeps its notice.
if ($Gate) { exit 0 }
Write-Host '[planning-with-files] No task_plan.md found -- no active planning session.'
exit 0
}
+3
View File
@@ -79,6 +79,9 @@ else
fi
if [ ! -f "$PLAN_FILE" ]; then
# Automatic gate checks have nothing to report without a plan (header:
# "plans stay silent in --gate mode"); the explicit report keeps its notice.
[ "$GATE" -eq 1 ] && exit 0
echo "[planning-with-files] No task_plan.md found — no active planning session."
exit 0
fi
@@ -18,6 +18,27 @@ param(
[switch]$Gated
)
# Windows PowerShell 5.1 can silently relocate a -File invocation when the
# inherited cwd contains wildcard characters such as [ or ]. Recover the
# physical cwd for a direct invocation before any project-relative paths are
# resolved.
if ($PSVersionTable.PSVersion.Major -eq 5 -and
[System.Management.Automation.WildcardPattern]::ContainsWildcardCharacters([Environment]::CurrentDirectory)) {
$processArgs = [Environment]::GetCommandLineArgs()
for ($index = 0; $index -lt ($processArgs.Length - 1); $index++) {
if ($processArgs[$index] -ieq '-File') {
$entryScript = $processArgs[$index + 1]
if (-not [IO.Path]::IsPathRooted($entryScript)) {
$entryScript = Join-Path ([Environment]::CurrentDirectory) $entryScript
}
if ([IO.Path]::GetFullPath($entryScript) -eq [IO.Path]::GetFullPath($PSCommandPath)) {
Set-Location -LiteralPath ([Environment]::CurrentDirectory)
}
break
}
}
}
$DATE = Get-Date -Format "yyyy-MM-dd"
# Resolve v3 opt-in mode. -Gated implies autonomous and is the stronger marker.
@@ -80,6 +101,26 @@ function Get-InheritedMode([string]$CurrentMode) {
return $CurrentMode
}
function Format-AttestationFailureReason {
param([object[]]$Output, [string]$Fallback)
$Reason = @(
$Output |
ForEach-Object {
if ($null -ne $_) { ($_.ToString()).Trim() }
} |
Where-Object { $_ }
) -join " "
$Reason = ($Reason -replace '\s+', ' ').Trim()
if ([string]::IsNullOrWhiteSpace($Reason)) {
return $Fallback
}
if ($Reason.Length -gt 300) {
return ($Reason.Substring(0, 297) + "...")
}
return $Reason
}
# Validate template
if ($Template -ne "default" -and $Template -ne "analytics") {
Write-Host "Unknown template: $Template (available: default, analytics). Using default."
@@ -136,20 +177,7 @@ if ($UsePlanDir) {
$Counter++
}
$TargetDir = Join-Path $PlanningRoot $PlanId
New-Item -ItemType Directory -Path $TargetDir -Force | Out-Null
# Reuse the selector's contained, atomic pointer replacement. Set-Content
# would follow a reparse point and truncate a hardlinked pointer in place,
# overwriting whichever file shares that inode.
$global:LASTEXITCODE = 0
try {
& $PlanSelector $PlanId *> $null
} catch {
$global:LASTEXITCODE = 1
}
if ($LASTEXITCODE -ne 0) {
Write-Error "Error: could not safely update the active plan pointer at $(Join-Path $PlanningRoot '.active_plan')."
exit 1
}
New-Item -ItemType Directory -Path $TargetDir -Force -ErrorAction Stop | Out-Null
$Mode = Get-InheritedMode $Mode
} else {
$TargetDir = (Get-Location).Path
@@ -173,11 +201,12 @@ if ($UsePlanDir) {
Write-Host "PLAN_ID=$PlanId"
}
try {
# Create task_plan.md if it doesn't exist
if (-not (Test-Path -LiteralPath $TaskPlanPath)) {
$AnalyticsPlan = Join-Path $TemplateDir "analytics_task_plan.md"
if ($Template -eq "analytics" -and (Test-Path $AnalyticsPlan)) {
Copy-Item -LiteralPath $AnalyticsPlan -Destination $TaskPlanPath
Copy-Item -LiteralPath $AnalyticsPlan -Destination $TaskPlanPath -ErrorAction Stop
} else {
@"
# Task Plan: [Brief Description]
@@ -226,7 +255,7 @@ Phase 1
## Errors Encountered
| Error | Resolution |
|-------|------------|
"@ | Out-File -LiteralPath $TaskPlanPath -Encoding UTF8
"@ | Out-File -LiteralPath $TaskPlanPath -Encoding UTF8 -ErrorAction Stop
}
Write-Host "Created $TaskPlanDisplay"
} else {
@@ -237,7 +266,7 @@ Phase 1
if (-not (Test-Path -LiteralPath $FindingsPath)) {
$AnalyticsFindings = Join-Path $TemplateDir "analytics_findings.md"
if ($Template -eq "analytics" -and (Test-Path $AnalyticsFindings)) {
Copy-Item -LiteralPath $AnalyticsFindings -Destination $FindingsPath
Copy-Item -LiteralPath $AnalyticsFindings -Destination $FindingsPath -ErrorAction Stop
} else {
@"
# Findings & Decisions
@@ -258,7 +287,7 @@ if (-not (Test-Path -LiteralPath $FindingsPath)) {
## Resources
-
"@ | Out-File -LiteralPath $FindingsPath -Encoding UTF8
"@ | Out-File -LiteralPath $FindingsPath -Encoding UTF8 -ErrorAction Stop
}
Write-Host "Created $FindingsDisplay"
} else {
@@ -287,7 +316,7 @@ if (-not (Test-Path -LiteralPath $ProgressPath)) {
### Errors
| Error | Resolution |
|-------|------------|
"@ | Out-File -LiteralPath $ProgressPath -Encoding UTF8
"@ | Out-File -LiteralPath $ProgressPath -Encoding UTF8 -ErrorAction Stop
} else {
@"
# Progress Log
@@ -308,12 +337,58 @@ if (-not (Test-Path -LiteralPath $ProgressPath)) {
### Errors
| Error | Resolution |
|-------|------------|
"@ | Out-File -LiteralPath $ProgressPath -Encoding UTF8
"@ | Out-File -LiteralPath $ProgressPath -Encoding UTF8 -ErrorAction Stop
}
Write-Host "Created $ProgressDisplay"
} else {
Write-Host "$ProgressDisplay already exists, skipping"
}
} catch {
Write-Error "Error: could not initialize planning files in '$TargetDir': $($_.Exception.Message)"
exit 1
}
if ($UsePlanDir) {
# Activate the named plan only after all three planning files are ready.
# This matches init-session.sh and prevents a failed initialization from
# leaving .active_plan pointed at a partial plan directory.
$pointerSet = $false
for ($attempt = 1; $attempt -le 5; $attempt++) {
$global:LASTEXITCODE = 0
try {
$selectorResult = & $PlanSelector $PlanId 2>&1
} catch {
$selectorResult = $_
$global:LASTEXITCODE = 1
}
if ($LASTEXITCODE -eq 0) {
$pointerSet = $true
break
}
# Another writer can replace the pointer between the selector's
# Get-Item and final-path check. Retry only that transient result.
$transientPointerRace = $selectorResult -is [System.Management.Automation.ErrorRecord] -and
$selectorResult.Exception.Message -in @(
'Error: could not set the active plan pointer: the active plan pointer became unsafe during replacement',
'Error: the active plan pointer must be a regular file within the project.'
)
if ($attempt -eq 5 -or -not $transientPointerRace) {
break
}
Start-Sleep -Milliseconds 50
$global:LASTEXITCODE = 0
try {
& $PlanSelector -VerifyRoot *> $null
} catch {
$global:LASTEXITCODE = 1
}
if ($LASTEXITCODE -ne 0) { break }
}
if (-not $pointerSet) {
Write-Error "Error: could not safely update the active plan pointer at $(Join-Path $PlanningRoot '.active_plan')."
exit 1
}
}
Write-Host ""
Write-Host "Planning files initialized!"
@@ -355,8 +430,11 @@ if ($Mode -ne "") {
# to another project or plan (#261, #237). Root mode clears both instead:
# the attester only falls back to the legacy ./task_plan.md when no
# selector is set, and a bound pin would make it refuse the root plan.
$AttestationSucceeded = $false
$AttestationCommand = "attest-plan"
$AttestationReason = "task_plan.md was not available for attestation"
$PlanFilePwf = Join-Path $PlanDirPwf "task_plan.md"
if (Test-Path -LiteralPath $PlanFilePwf) {
if (Test-Path -LiteralPath $PlanFilePwf -PathType Leaf) {
$HadPlanId = Test-Path Env:PLAN_ID
$PreviousPlanId = $env:PLAN_ID
$HadPlanRoot = Test-Path Env:PWF_PLAN_ROOT
@@ -370,21 +448,54 @@ if ($Mode -ne "") {
Remove-Item Env:PLAN_ID -ErrorAction SilentlyContinue
}
# A called script can return without changing $LASTEXITCODE, so
# clear the inherited value before every attempt. Both a non-zero
# status and a terminating exception mean the plan is not attested.
$global:LASTEXITCODE = 0
$AttestOutput = @()
$IsWindowsHost = [Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT
if ($IsWindowsHost) {
$AttestPs1 = Join-Path $ScriptDir "attest-plan.ps1"
if (Test-Path -LiteralPath $AttestPs1) {
& $AttestPs1 *> $null
$AttestationCommand = "attest-plan.ps1"
$AttestPs1 = Join-Path $ScriptDir $AttestationCommand
if (Test-Path -LiteralPath $AttestPs1 -PathType Leaf) {
$AttestOutput = @(& $AttestPs1 2>&1)
$AttestExitCode = $LASTEXITCODE
if ($AttestExitCode -eq 0) {
$AttestationSucceeded = $true
$AttestationReason = ""
} else {
$AttestationReason = Format-AttestationFailureReason `
-Output $AttestOutput `
-Fallback "$AttestationCommand exited with code $AttestExitCode"
}
} else {
$AttestationReason = "$AttestationCommand was not found beside init-session.ps1"
}
} else {
$AttestSh = Join-Path $ScriptDir "attest-plan.sh"
$AttestationCommand = "attest-plan.sh"
$AttestSh = Join-Path $ScriptDir $AttestationCommand
$Sh = Get-Command sh -ErrorAction SilentlyContinue
if ($Sh -and (Test-Path -LiteralPath $AttestSh)) {
& $Sh.Path $AttestSh *> $null
if ($Sh -and (Test-Path -LiteralPath $AttestSh -PathType Leaf)) {
$AttestOutput = @(& $Sh.Path $AttestSh 2>&1)
$AttestExitCode = $LASTEXITCODE
if ($AttestExitCode -eq 0) {
$AttestationSucceeded = $true
$AttestationReason = ""
} else {
$AttestationReason = Format-AttestationFailureReason `
-Output $AttestOutput `
-Fallback "$AttestationCommand exited with code $AttestExitCode"
}
} elseif (-not $Sh) {
$AttestationReason = "sh was not found; $AttestationCommand could not run"
} else {
$AttestationReason = "$AttestationCommand was not found beside init-session.ps1"
}
}
} catch {
# attestation failure must not abort init; the mode marker still stands.
$AttestationReason = Format-AttestationFailureReason `
-Output @($_) `
-Fallback "$AttestationCommand failed"
} finally {
if ($HadPlanId) {
$env:PLAN_ID = $PreviousPlanId
@@ -399,5 +510,9 @@ if ($Mode -ne "") {
}
}
Write-Host "Mode: $MarkerText (attested, gate counter reset)"
if ($AttestationSucceeded) {
Write-Host "Mode: $MarkerText (attested, gate counter reset)"
} else {
Write-Host "Mode: $MarkerText (NOT attested: $AttestationReason; run $AttestationCommand before the first hook fire)"
}
}
@@ -186,6 +186,10 @@ apply_v3_mode() {
_mode_plan="$2"
[ -z "$MODE" ] && return 0
ATTESTATION_OK=0
ATTESTATION_COMMAND="attest-plan.sh"
ATTESTATION_REASON="task_plan.md was not available for attestation"
# (a) reset the gate block counter and drop any stale gate ledger so a prior
# run's high block count cannot let the next run stop instantly.
printf '0\n' > "${_mode_dir}/.stop_blocks"
@@ -210,13 +214,54 @@ apply_v3_mode() {
# ./task_plan.md when no selector is set, and a bound pin would make it
# refuse the root plan. Run from the project root (CWD here) so both
# resolutions land.
_attest="${SCRIPT_DIR}/attest-plan.sh"
if [ -f "${_attest}" ] && [ -f "${_mode_plan}" ]; then
if [ "$SLUG_MODE" -eq 1 ]; then
PWF_PLAN_ROOT="$PWD" PLAN_ID="${PLAN_ID}" sh "${_attest}" >/dev/null 2>&1 || true
_attest="${SCRIPT_DIR}/${ATTESTATION_COMMAND}"
if [ ! -f "${_attest}" ]; then
ATTESTATION_REASON="${ATTESTATION_COMMAND} was not found beside init-session.sh"
return 0
fi
if [ ! -f "${_mode_plan}" ]; then
return 0
fi
if [ "$SLUG_MODE" -eq 1 ]; then
if _attest_output="$(PWF_PLAN_ROOT="$PWD" PLAN_ID="${PLAN_ID}" sh "${_attest}" 2>&1)"; then
ATTESTATION_OK=1
ATTESTATION_REASON=""
return 0
else
PWF_PLAN_ROOT="" PLAN_ID="" sh "${_attest}" >/dev/null 2>&1 || true
_attest_rc=$?
fi
else
if _attest_output="$(PWF_PLAN_ROOT="" PLAN_ID="" sh "${_attest}" 2>&1)"; then
ATTESTATION_OK=1
ATTESTATION_REASON=""
return 0
else
_attest_rc=$?
fi
fi
_attest_reason="$(
printf '%s\n' "${_attest_output}" |
sed -n '/[^[:space:]]/ { s/[[:space:]][[:space:]]*/ /g; s/^ //; s/ $//; p; q; }' |
cut -c1-300
)"
if [ -n "${_attest_reason}" ]; then
ATTESTATION_REASON="${_attest_reason}"
else
ATTESTATION_REASON="${ATTESTATION_COMMAND} exited with code ${_attest_rc}"
fi
return 0
}
print_v3_mode_status() {
_status_dir="$1"
_marker="$(cat "${_status_dir}/.mode")"
if [ "${ATTESTATION_OK:-0}" -eq 1 ]; then
printf 'Mode: %s (attested, gate counter reset)\n' "${_marker}"
else
printf 'Mode: %s (NOT attested: %s; run %s before the first hook fire)\n' \
"${_marker}" "${ATTESTATION_REASON:-attestation failed}" "${ATTESTATION_COMMAND:-attest-plan.sh}"
fi
}
@@ -431,7 +476,7 @@ if [ "$SLUG_MODE" -eq 1 ]; then
echo "Pin this terminal to the plan for parallel sessions:"
echo " export PLAN_ID=$PLAN_ID"
if [ -n "$MODE" ]; then
echo "Mode: $(cat "${PLAN_DIR}/.mode") (attested, gate counter reset)"
print_v3_mode_status "${PLAN_DIR}"
fi
else
PROJECT_NAME="${PROJECT_NAME:-project}"
@@ -442,6 +487,6 @@ else
echo "Planning files initialized!"
echo "Files: task_plan.md, findings.md, progress.md"
if [ -n "$MODE" ]; then
echo "Mode: $(cat "$(pwd)/.mode") (attested, gate counter reset)"
print_v3_mode_status "$(pwd)"
fi
fi
@@ -468,10 +468,13 @@ def _opencode_state_annotation(state: Any) -> str:
def _format_opencode_part(data: Dict[str, Any], session_id: str) -> Optional[Dict[str, Any]]:
"""Print-ready summary for one OpenCode part row."""
if not isinstance(data, dict):
return None
ptype = data.get('type')
short = safe_session_label(session_id)
if ptype == 'tool':
tool = (data.get('tool') or '').lower()
tool_value = data.get('tool')
tool = tool_value.lower() if isinstance(tool_value, str) else ''
state = data.get('state') or {}
input_ = state.get('input') if isinstance(state, dict) else None
input_ = input_ if isinstance(input_, dict) else {}
@@ -486,7 +489,8 @@ def _format_opencode_part(data: Dict[str, Any], session_id: str) -> Optional[Dic
return {'session': short, 'summary': f"Tool bash: {cmd}{outcome}"}
return {'session': short, 'summary': f"Tool {tool}{outcome}"}
if ptype == 'text':
text = (data.get('text') or '')[:300]
text_value = data.get('text')
text = text_value[:300] if isinstance(text_value, str) else ''
if text.strip():
return {'session': short, 'summary': f"text: {text}"}
return None
@@ -128,9 +128,58 @@ function Test-SafeActiveFile {
$linked = ([string]$item.LinkType) -in @('SymbolicLink', 'Junction')
return -not $item.PSIsContainer -and
($AllowLink -or -not $linked) -and
($AllowLink -or -not $item.IsReadOnly) -and
(Test-WithinRoot $ActiveFile)
}
function Test-SafeOwnedBackupFile {
param([string]$Path)
$item = Get-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue
if (-not $item) { return $false }
$linked = ([string]$item.LinkType) -in @('SymbolicLink', 'Junction')
return -not $item.PSIsContainer -and -not $linked -and (Test-WithinRoot $Path)
}
function Resolve-OwnedPointerBackup {
param([string]$BackupFile, [switch]$ReplaceSucceeded)
# ReplaceFile can move the old destination before failing to move the new
# file. A caller-named backup makes that intermediate state attributable to
# this invocation, so recover or delete only this GUID path. Never glob for
# ~RF*.TMP: a concurrent selector may own those files.
$maxAttempts = 20
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
$backupItem = Get-Item -LiteralPath $BackupFile -Force -ErrorAction SilentlyContinue
if (-not $backupItem) { return $true }
# A failed inspection is transient under concurrent writers: retry.
if (-not (Test-SafeOwnedBackupFile $BackupFile)) { Start-Sleep -Milliseconds 25; continue }
$currentActive = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
# After our own successful replacement the backup holds a superseded
# value: only delete it, never move it back over a newer pointer.
if ($ReplaceSucceeded -or $currentActive) {
if (-not $ReplaceSucceeded -and -not (Test-SafeActiveFile)) { Start-Sleep -Milliseconds 25; continue }
try {
[IO.File]::Delete($BackupFile)
return $true
} catch {
if ($attempt -eq $maxAttempts) { return $false }
}
} else {
try {
[IO.File]::Move($BackupFile, $ActiveFile)
return $true
} catch {
# Another selector may have recreated the pointer between the
# absence check and Move. Reinspect before deciding whether the
# owned backup is now redundant.
if ($attempt -eq $maxAttempts) { return $false }
}
}
Start-Sleep -Milliseconds 25
}
return $false
}
function Get-CurrentActivePlan {
if (-not (Test-SafeActiveFile -AllowLink)) { return "" }
try {
@@ -305,9 +354,16 @@ if ($activeItem -and -not (Test-SafeActiveFile)) {
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
# Replace the directory entry instead of truncating an existing inode: a
# hardlinked pointer must not overwrite another file. The exclusive temporary
# file lives beside the pointer, keeping replacement on the same filesystem.
$tempFile = Join-Path $PlanRoot ('.active_plan.' + [guid]::NewGuid().ToString('N') + '.tmp')
# file and caller-owned backup live beside the pointer, keeping replacement on
# the same filesystem. Naming the backup prevents ReplaceFile from inventing
# an unowned ~RF*.TMP path if its final rename fails.
$operationId = [guid]::NewGuid().ToString('N')
$tempFile = Join-Path $PlanRoot ('.active_plan.' + $operationId + '.tmp')
$backupFile = Join-Path $PlanRoot ('.active_plan.' + $operationId + '.replace-backup')
$createdTemp = $false
$failureMessage = ""
$backupResolved = $true
$replaced = $false
try {
$stream = [IO.File]::Open($tempFile, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
$createdTemp = $true
@@ -316,19 +372,48 @@ try {
$stream.Write($bytes, 0, $bytes.Length)
$stream.Flush()
} finally { $stream.Dispose() }
if ($activeItem) {
[IO.File]::Replace($tempFile, $ActiveFile, [NullString]::Value)
} else {
[IO.File]::Move($tempFile, $ActiveFile)
$maxAttempts = 20
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
try {
$currentActive = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
if ($currentActive) {
if (-not (Test-SafeActiveFile)) {
throw [InvalidOperationException]::new("the active plan pointer became unsafe during replacement")
}
[IO.File]::Replace($tempFile, $ActiveFile, $backupFile)
} else {
[IO.File]::Move($tempFile, $ActiveFile)
}
$replaced = $true
break
} catch [IO.IOException] {
if (-not (Resolve-OwnedPointerBackup -BackupFile $backupFile)) {
throw [IO.IOException]::new("could not safely recover the active plan pointer after replacement failure")
}
if ($attempt -eq $maxAttempts) { throw }
Start-Sleep -Milliseconds 25
}
}
} catch {
Write-Error "Error: could not set the active plan pointer: $($_.Exception.Message)"
exit 1
$failureMessage = $_.Exception.Message
} finally {
if (-not (Resolve-OwnedPointerBackup -BackupFile $backupFile -ReplaceSucceeded:$replaced)) {
# The pointer was written; a leftover backup is clutter, not failure.
if ($replaced) { Write-Warning "could not remove the replacement backup $backupFile" }
else { $backupResolved = $false }
}
if ($createdTemp -and (Test-Path -LiteralPath $tempFile)) {
Remove-Item -LiteralPath $tempFile -Force -ErrorAction SilentlyContinue
}
}
if (-not $backupResolved) {
Write-Error "Error: could not safely recover the active plan pointer backup."
exit 1
}
if ($failureMessage) {
Write-Error "Error: could not set the active plan pointer: $failureMessage"
exit 1
}
Write-Output "Active plan set to: $PlanId"
Write-Output "Path: $PlanDir"
Write-Output ""
@@ -8,6 +8,12 @@ State the analytical question or intended deliverable in one clear sentence.
[One sentence describing the analytical objective]
## Next Step
Record the single analytical action that should happen next. Update it whenever the active phase or immediate action changes.
[The single next analytical action. Update whenever phase status changes.]
## Current Phase
Name the phase currently being worked on.
@@ -76,6 +82,6 @@ Record each distinct error, the attempt number, and the resolution. Change the a
## Notes
- Update phase status as work progresses: `pending` to `in_progress` to `complete`.
- Re-read the goal and current phase before major analytical decisions.
- Re-read the goal, next step, and current phase before major analytical decisions.
- Log errors promptly so failed approaches are not repeated.
- Record query results and visual evidence in findings.md.
@@ -372,6 +372,41 @@ describe("init and status", () => {
expect(buildContext(root, dir)).toContain("[planning-with-files] plan: ")
})
it("updates a regular active plan pointer through a directory junction project root", () => {
const linkedRoot = `${root}-junction`
fs.symlinkSync(root, linkedRoot, "junction")
try {
const first = initPlan(linkedRoot, { name: "First" }, env)
expect(first.ok).toBe(true)
const second = initPlan(linkedRoot, { name: "Second" }, env)
expect(second.ok).toBe(true)
const pointer = path.join(root, ".planning", ".active_plan")
expect(fs.lstatSync(pointer).isFile()).toBe(true)
expect(fs.readFileSync(pointer, "utf8")).toBe(`${second.plan_id}\n`)
} finally {
fs.rmSync(linkedRoot, { recursive: true, force: true })
}
})
it("refuses a linked planning directory without changing its external pointer", () => {
const outside = `${root}-outside`
const planning = path.join(root, ".planning")
fs.mkdirSync(outside)
const pointer = path.join(outside, ".active_plan")
fs.writeFileSync(pointer, "external-plan\n")
fs.symlinkSync(outside, planning, "junction")
try {
const result = initPlan(root, { name: "Blocked" }, env)
expect(result.ok).toBe(false)
expect(result.error).toContain("unsafe active plan pointer")
expect(fs.readFileSync(pointer, "utf8")).toBe("external-plan\n")
expect(fs.readdirSync(outside)).toEqual([".active_plan"])
} finally {
fs.rmSync(planning, { recursive: true, force: true })
fs.rmSync(outside, { recursive: true, force: true })
}
})
it("uses a real skill template when one is discoverable, and rejects unknown modes", () => {
const skill = path.join(root, ".agents", "skills", "planning-with-files", "templates")
fs.mkdirSync(skill, { recursive: true })
@@ -86,6 +86,40 @@ function writeText(target: string, text: string): void {
fs.writeFileSync(target, text, { encoding: "utf8" })
}
function activePlanPointerError(planningRoot: string): string | null {
const pointer = path.join(planningRoot, ".active_plan")
const realRoot = realpathOrNull(planningRoot)
if (!isRealDir(planningRoot) || !realRoot) return `refusing to replace unsafe active plan pointer: ${pointer}`
if (!fs.existsSync(pointer)) return null
const st = lstatSafe(pointer)
const real = realpathOrNull(pointer)
return !st || !st.isFile() || st.isSymbolicLink() || st.nlink > 1 || !real || !isInside(real, realRoot)
? `refusing to replace unsafe active plan pointer: ${pointer}`
: null
}
function writeActivePlan(planningRoot: string, planId: string): string | null {
const pointer = path.join(planningRoot, ".active_plan")
const unsafe = activePlanPointerError(planningRoot)
if (unsafe) return unsafe
const tmp = path.join(planningRoot, `.active_plan.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`)
let fd: number | null = null
try {
fd = fs.openSync(tmp, "wx")
fs.writeFileSync(fd, `${planId}\n`, { encoding: "utf8" })
fs.closeSync(fd)
fd = null
fs.renameSync(tmp, pointer)
return null
} catch (err) {
return `could not safely update active plan pointer: ${err instanceof Error ? err.message : String(err)}`
} finally {
if (fd !== null) fs.closeSync(fd)
try { fs.unlinkSync(tmp) } catch { /* already renamed or absent */ }
}
}
function realpathOrNull(target: string): string | null {
try {
return fs.realpathSync(target)
@@ -736,8 +770,11 @@ export function initPlan(
fs.mkdirSync(planningRoot, { recursive: true })
planId = `${new Date().toISOString().slice(0, 10)}-${slug}`
planDir = path.join(planningRoot, planId)
const pointerError = activePlanPointerError(planningRoot)
if (pointerError) return { ok: false, error: pointerError, project_dir: root }
fs.mkdirSync(planDir, { recursive: true })
writeText(path.join(planningRoot, ".active_plan"), `${planId}\n`)
const writeError = writeActivePlan(planningRoot, planId)
if (writeError) return { ok: false, error: writeError, project_dir: root }
}
const created = copyTemplates(planDir, templatesDir, template)
const result: InitResult = {
+1 -1
View File
@@ -36,7 +36,7 @@ hooks:
- type: command
command: "SH=\"\"; for c in \"${PWF_SCRIPT_DIR}/skill-hook.sh\" \"${CLAUDE_SKILL_DIR}/scripts/skill-hook.sh\" \"$HOME/.factory/skills/planning-with-files/scripts/skill-hook.sh\" \"$HOME/.claude/skills/planning-with-files/scripts/skill-hook.sh\" \"$HOME/.claude/plugins/marketplaces/planning-with-files/scripts/skill-hook.sh\"; do [ -f \"$c\" ] && { SH=\"$c\"; break; }; done; [ -n \"$SH\" ] && sh \"$SH\" --event=precompact; exit 0"
metadata:
version: "3.20.4"
version: "3.21.0"
---
# Planning with Files
@@ -6,16 +6,19 @@
# attested hash, surfacing a "[PLAN TAMPERED]" warning instead.
#
# Resolution:
# 1. $PLAN_ID env var → ./.planning/$PLAN_ID/
# 2. ./.planning/.active_plan
# 3. Newest ./.planning/<dir>/ by mtime
# 4. Current directory when it is .planning/<valid-slug>/
# 5. Legacy ./task_plan.md at project root
# 1. --target root or <plan-id> → root or named plan under $PWF_PLAN_ROOT (default: cwd)
# 2. $PLAN_ID env var → ./.planning/$PLAN_ID/
# 3. ./.planning/.active_plan
# 4. Newest ./.planning/<dir>/ by mtime
# 5. Current directory when it is .planning/<valid-slug>/
# 6. Legacy ./task_plan.md at project root
#
# Usage:
# sh scripts/attest-plan.sh # attest the active plan
# sh scripts/attest-plan.sh --show # print the stored hash
# sh scripts/attest-plan.sh --clear # remove the attestation (re-open the plan)
# sh scripts/attest-plan.sh --target root # attest the legacy root plan
# sh scripts/attest-plan.sh --target <plan-id> # attest one named plan
set -u
@@ -42,6 +45,39 @@ resolve_from_slug_cwd() {
}
resolve_plan_file() {
if [ -n "${target:-}" ]; then
if [ "${target}" = "root" ]; then
target_root="."
if [ -n "${PWF_PLAN_ROOT:-}" ]; then
# Match the shared resolver's supported absolute local pins.
# A valid project-root junction is intentional, so resolve it.
case "${PWF_PLAN_ROOT}" in
\\\\*|//*|[A-Za-z]:[!\\/]*) return 1 ;;
/*|[A-Za-z]:[\\/]*) ;;
*) return 1 ;;
esac
target_root="$(cd "${PWF_PLAN_ROOT}" 2>/dev/null && pwd -P)" || return 1
fi
[ ! -L "${target_root}/task_plan.md" ] || return 1
[ -f "${target_root}/task_plan.md" ] || return 1
printf "%s\n" "${target_root}/task_plan.md"
return 0
fi
slug_is_valid "${target}" || return 1
# Reuse the binding, project-pin and containment rules. A flag replaces
# PLAN_ID for this call only; it does not change the shared pointer.
[ -f "${RESOLVER}" ] || return 1
target_dir="$(PLAN_ID="${target}" sh "${RESOLVER}" 2>/dev/null)" || return 1
[ -n "${target_dir}" ] || return 1
# Use the shell's physical path spelling: native Windows backslashes
# make sha256sum prefix its output with an escaped-filename marker.
target_dir="$(cd "${target_dir}" 2>/dev/null && pwd -P)" || return 1
[ ! -L "${target_dir}/task_plan.md" ] || return 1
[ -f "${target_dir}/task_plan.md" ] || return 1
printf "%s\n" "${target_dir}/task_plan.md"
return 0
fi
plan_dir=""
if [ -f "${RESOLVER}" ]; then
plan_dir="$(sh "${RESOLVER}" 2>/dev/null)"
@@ -80,7 +116,9 @@ resolve_plan_file() {
attestation_path_for() {
plan_file="$1"
plan_dir="$(dirname "${plan_file}")"
if [ "${plan_dir}" = "." ]; then
if [ "${target:-}" = "root" ]; then
printf "%s\n" "${plan_dir}/.plan-attestation"
elif [ "${plan_dir}" = "." ]; then
# Legacy mode: store at project root.
printf "%s\n" "./.plan-attestation"
else
@@ -101,14 +139,21 @@ compute_hash() {
}
mode="attest"
target=""
usage_error() {
printf "Usage: %s [--show|--clear|--target root|<plan-id>]\n" "$0" >&2
exit 2
}
case "${1:-}" in
--show) mode="show" ;;
--clear) mode="clear" ;;
"") mode="attest" ;;
*)
printf "Usage: %s [--show|--clear]\n" "$0" >&2
exit 2
--show) [ "$#" -eq 1 ] || usage_error; mode="show" ;;
--clear) [ "$#" -eq 1 ] || usage_error; mode="clear" ;;
--target)
[ "$#" -eq 2 ] || usage_error
[ -n "$2" ] || usage_error
target="$2"
;;
"") [ "$#" -eq 0 ] || usage_error ;;
*) usage_error ;;
esac
plan_file="$(resolve_plan_file)" || {
@@ -117,7 +162,9 @@ plan_file="$(resolve_plan_file)" || {
# a mistyped PLAN_ID attested a DIFFERENT plan at rc=0, and an operator
# who now sees a generic not-found is likely to go looking for the wrong
# problem. The selectors are bindings, so say which one refused.
if [ -n "${PLAN_ID:-}" ]; then
if [ -n "${target:-}" ]; then
printf "[plan-attest] --target %s did not resolve to a plan. An explicit target is a binding: nothing was attested and no other plan was substituted.\n" "${target}" >&2
elif [ -n "${PLAN_ID:-}" ]; then
printf "[plan-attest] PLAN_ID=%s names no plan directory under .planning. An explicit selector is a binding: nothing was attested and no other plan was substituted.\n" "${PLAN_ID}" >&2
elif [ -n "${PWF_PLAN_ROOT:-}" ]; then
printf "[plan-attest] PWF_PLAN_ROOT=%s did not resolve to a project root holding a plan. An explicit pin is a binding: nothing was attested and no other plan was substituted.\n" "${PWF_PLAN_ROOT}" >&2
@@ -156,6 +203,8 @@ case "${mode}" in
fi
;;
attest)
printf "Plan: %s\n" "${plan_file}"
printf "Attestation: %s\n" "${attestation_file}"
hash_val="$(compute_hash "${plan_file}")" || exit 1
# v2.40: protect the write with an advisory flock when available so
@@ -51,6 +51,8 @@ if ($PlanFile -ne "") {
}
if (-not (Test-Path $PlanFile)) {
# Automatic gate checks have nothing to report without a plan; the explicit report keeps its notice.
if ($Gate) { exit 0 }
Write-Host '[planning-with-files] No task_plan.md found -- no active planning session.'
exit 0
}
@@ -79,6 +79,9 @@ else
fi
if [ ! -f "$PLAN_FILE" ]; then
# Automatic gate checks have nothing to report without a plan (header:
# "plans stay silent in --gate mode"); the explicit report keeps its notice.
[ "$GATE" -eq 1 ] && exit 0
echo "[planning-with-files] No task_plan.md found — no active planning session."
exit 0
fi
@@ -18,6 +18,27 @@ param(
[switch]$Gated
)
# Windows PowerShell 5.1 can silently relocate a -File invocation when the
# inherited cwd contains wildcard characters such as [ or ]. Recover the
# physical cwd for a direct invocation before any project-relative paths are
# resolved.
if ($PSVersionTable.PSVersion.Major -eq 5 -and
[System.Management.Automation.WildcardPattern]::ContainsWildcardCharacters([Environment]::CurrentDirectory)) {
$processArgs = [Environment]::GetCommandLineArgs()
for ($index = 0; $index -lt ($processArgs.Length - 1); $index++) {
if ($processArgs[$index] -ieq '-File') {
$entryScript = $processArgs[$index + 1]
if (-not [IO.Path]::IsPathRooted($entryScript)) {
$entryScript = Join-Path ([Environment]::CurrentDirectory) $entryScript
}
if ([IO.Path]::GetFullPath($entryScript) -eq [IO.Path]::GetFullPath($PSCommandPath)) {
Set-Location -LiteralPath ([Environment]::CurrentDirectory)
}
break
}
}
}
$DATE = Get-Date -Format "yyyy-MM-dd"
# Resolve v3 opt-in mode. -Gated implies autonomous and is the stronger marker.
@@ -80,6 +101,26 @@ function Get-InheritedMode([string]$CurrentMode) {
return $CurrentMode
}
function Format-AttestationFailureReason {
param([object[]]$Output, [string]$Fallback)
$Reason = @(
$Output |
ForEach-Object {
if ($null -ne $_) { ($_.ToString()).Trim() }
} |
Where-Object { $_ }
) -join " "
$Reason = ($Reason -replace '\s+', ' ').Trim()
if ([string]::IsNullOrWhiteSpace($Reason)) {
return $Fallback
}
if ($Reason.Length -gt 300) {
return ($Reason.Substring(0, 297) + "...")
}
return $Reason
}
# Validate template
if ($Template -ne "default" -and $Template -ne "analytics") {
Write-Host "Unknown template: $Template (available: default, analytics). Using default."
@@ -136,20 +177,7 @@ if ($UsePlanDir) {
$Counter++
}
$TargetDir = Join-Path $PlanningRoot $PlanId
New-Item -ItemType Directory -Path $TargetDir -Force | Out-Null
# Reuse the selector's contained, atomic pointer replacement. Set-Content
# would follow a reparse point and truncate a hardlinked pointer in place,
# overwriting whichever file shares that inode.
$global:LASTEXITCODE = 0
try {
& $PlanSelector $PlanId *> $null
} catch {
$global:LASTEXITCODE = 1
}
if ($LASTEXITCODE -ne 0) {
Write-Error "Error: could not safely update the active plan pointer at $(Join-Path $PlanningRoot '.active_plan')."
exit 1
}
New-Item -ItemType Directory -Path $TargetDir -Force -ErrorAction Stop | Out-Null
$Mode = Get-InheritedMode $Mode
} else {
$TargetDir = (Get-Location).Path
@@ -173,11 +201,12 @@ if ($UsePlanDir) {
Write-Host "PLAN_ID=$PlanId"
}
try {
# Create task_plan.md if it doesn't exist
if (-not (Test-Path -LiteralPath $TaskPlanPath)) {
$AnalyticsPlan = Join-Path $TemplateDir "analytics_task_plan.md"
if ($Template -eq "analytics" -and (Test-Path $AnalyticsPlan)) {
Copy-Item -LiteralPath $AnalyticsPlan -Destination $TaskPlanPath
Copy-Item -LiteralPath $AnalyticsPlan -Destination $TaskPlanPath -ErrorAction Stop
} else {
@"
# Task Plan: [Brief Description]
@@ -226,7 +255,7 @@ Phase 1
## Errors Encountered
| Error | Resolution |
|-------|------------|
"@ | Out-File -LiteralPath $TaskPlanPath -Encoding UTF8
"@ | Out-File -LiteralPath $TaskPlanPath -Encoding UTF8 -ErrorAction Stop
}
Write-Host "Created $TaskPlanDisplay"
} else {
@@ -237,7 +266,7 @@ Phase 1
if (-not (Test-Path -LiteralPath $FindingsPath)) {
$AnalyticsFindings = Join-Path $TemplateDir "analytics_findings.md"
if ($Template -eq "analytics" -and (Test-Path $AnalyticsFindings)) {
Copy-Item -LiteralPath $AnalyticsFindings -Destination $FindingsPath
Copy-Item -LiteralPath $AnalyticsFindings -Destination $FindingsPath -ErrorAction Stop
} else {
@"
# Findings & Decisions
@@ -258,7 +287,7 @@ if (-not (Test-Path -LiteralPath $FindingsPath)) {
## Resources
-
"@ | Out-File -LiteralPath $FindingsPath -Encoding UTF8
"@ | Out-File -LiteralPath $FindingsPath -Encoding UTF8 -ErrorAction Stop
}
Write-Host "Created $FindingsDisplay"
} else {
@@ -287,7 +316,7 @@ if (-not (Test-Path -LiteralPath $ProgressPath)) {
### Errors
| Error | Resolution |
|-------|------------|
"@ | Out-File -LiteralPath $ProgressPath -Encoding UTF8
"@ | Out-File -LiteralPath $ProgressPath -Encoding UTF8 -ErrorAction Stop
} else {
@"
# Progress Log
@@ -308,12 +337,58 @@ if (-not (Test-Path -LiteralPath $ProgressPath)) {
### Errors
| Error | Resolution |
|-------|------------|
"@ | Out-File -LiteralPath $ProgressPath -Encoding UTF8
"@ | Out-File -LiteralPath $ProgressPath -Encoding UTF8 -ErrorAction Stop
}
Write-Host "Created $ProgressDisplay"
} else {
Write-Host "$ProgressDisplay already exists, skipping"
}
} catch {
Write-Error "Error: could not initialize planning files in '$TargetDir': $($_.Exception.Message)"
exit 1
}
if ($UsePlanDir) {
# Activate the named plan only after all three planning files are ready.
# This matches init-session.sh and prevents a failed initialization from
# leaving .active_plan pointed at a partial plan directory.
$pointerSet = $false
for ($attempt = 1; $attempt -le 5; $attempt++) {
$global:LASTEXITCODE = 0
try {
$selectorResult = & $PlanSelector $PlanId 2>&1
} catch {
$selectorResult = $_
$global:LASTEXITCODE = 1
}
if ($LASTEXITCODE -eq 0) {
$pointerSet = $true
break
}
# Another writer can replace the pointer between the selector's
# Get-Item and final-path check. Retry only that transient result.
$transientPointerRace = $selectorResult -is [System.Management.Automation.ErrorRecord] -and
$selectorResult.Exception.Message -in @(
'Error: could not set the active plan pointer: the active plan pointer became unsafe during replacement',
'Error: the active plan pointer must be a regular file within the project.'
)
if ($attempt -eq 5 -or -not $transientPointerRace) {
break
}
Start-Sleep -Milliseconds 50
$global:LASTEXITCODE = 0
try {
& $PlanSelector -VerifyRoot *> $null
} catch {
$global:LASTEXITCODE = 1
}
if ($LASTEXITCODE -ne 0) { break }
}
if (-not $pointerSet) {
Write-Error "Error: could not safely update the active plan pointer at $(Join-Path $PlanningRoot '.active_plan')."
exit 1
}
}
Write-Host ""
Write-Host "Planning files initialized!"
@@ -355,8 +430,11 @@ if ($Mode -ne "") {
# to another project or plan (#261, #237). Root mode clears both instead:
# the attester only falls back to the legacy ./task_plan.md when no
# selector is set, and a bound pin would make it refuse the root plan.
$AttestationSucceeded = $false
$AttestationCommand = "attest-plan"
$AttestationReason = "task_plan.md was not available for attestation"
$PlanFilePwf = Join-Path $PlanDirPwf "task_plan.md"
if (Test-Path -LiteralPath $PlanFilePwf) {
if (Test-Path -LiteralPath $PlanFilePwf -PathType Leaf) {
$HadPlanId = Test-Path Env:PLAN_ID
$PreviousPlanId = $env:PLAN_ID
$HadPlanRoot = Test-Path Env:PWF_PLAN_ROOT
@@ -370,21 +448,54 @@ if ($Mode -ne "") {
Remove-Item Env:PLAN_ID -ErrorAction SilentlyContinue
}
# A called script can return without changing $LASTEXITCODE, so
# clear the inherited value before every attempt. Both a non-zero
# status and a terminating exception mean the plan is not attested.
$global:LASTEXITCODE = 0
$AttestOutput = @()
$IsWindowsHost = [Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT
if ($IsWindowsHost) {
$AttestPs1 = Join-Path $ScriptDir "attest-plan.ps1"
if (Test-Path -LiteralPath $AttestPs1) {
& $AttestPs1 *> $null
$AttestationCommand = "attest-plan.ps1"
$AttestPs1 = Join-Path $ScriptDir $AttestationCommand
if (Test-Path -LiteralPath $AttestPs1 -PathType Leaf) {
$AttestOutput = @(& $AttestPs1 2>&1)
$AttestExitCode = $LASTEXITCODE
if ($AttestExitCode -eq 0) {
$AttestationSucceeded = $true
$AttestationReason = ""
} else {
$AttestationReason = Format-AttestationFailureReason `
-Output $AttestOutput `
-Fallback "$AttestationCommand exited with code $AttestExitCode"
}
} else {
$AttestationReason = "$AttestationCommand was not found beside init-session.ps1"
}
} else {
$AttestSh = Join-Path $ScriptDir "attest-plan.sh"
$AttestationCommand = "attest-plan.sh"
$AttestSh = Join-Path $ScriptDir $AttestationCommand
$Sh = Get-Command sh -ErrorAction SilentlyContinue
if ($Sh -and (Test-Path -LiteralPath $AttestSh)) {
& $Sh.Path $AttestSh *> $null
if ($Sh -and (Test-Path -LiteralPath $AttestSh -PathType Leaf)) {
$AttestOutput = @(& $Sh.Path $AttestSh 2>&1)
$AttestExitCode = $LASTEXITCODE
if ($AttestExitCode -eq 0) {
$AttestationSucceeded = $true
$AttestationReason = ""
} else {
$AttestationReason = Format-AttestationFailureReason `
-Output $AttestOutput `
-Fallback "$AttestationCommand exited with code $AttestExitCode"
}
} elseif (-not $Sh) {
$AttestationReason = "sh was not found; $AttestationCommand could not run"
} else {
$AttestationReason = "$AttestationCommand was not found beside init-session.ps1"
}
}
} catch {
# attestation failure must not abort init; the mode marker still stands.
$AttestationReason = Format-AttestationFailureReason `
-Output @($_) `
-Fallback "$AttestationCommand failed"
} finally {
if ($HadPlanId) {
$env:PLAN_ID = $PreviousPlanId
@@ -399,5 +510,9 @@ if ($Mode -ne "") {
}
}
Write-Host "Mode: $MarkerText (attested, gate counter reset)"
if ($AttestationSucceeded) {
Write-Host "Mode: $MarkerText (attested, gate counter reset)"
} else {
Write-Host "Mode: $MarkerText (NOT attested: $AttestationReason; run $AttestationCommand before the first hook fire)"
}
}
@@ -186,6 +186,10 @@ apply_v3_mode() {
_mode_plan="$2"
[ -z "$MODE" ] && return 0
ATTESTATION_OK=0
ATTESTATION_COMMAND="attest-plan.sh"
ATTESTATION_REASON="task_plan.md was not available for attestation"
# (a) reset the gate block counter and drop any stale gate ledger so a prior
# run's high block count cannot let the next run stop instantly.
printf '0\n' > "${_mode_dir}/.stop_blocks"
@@ -210,13 +214,54 @@ apply_v3_mode() {
# ./task_plan.md when no selector is set, and a bound pin would make it
# refuse the root plan. Run from the project root (CWD here) so both
# resolutions land.
_attest="${SCRIPT_DIR}/attest-plan.sh"
if [ -f "${_attest}" ] && [ -f "${_mode_plan}" ]; then
if [ "$SLUG_MODE" -eq 1 ]; then
PWF_PLAN_ROOT="$PWD" PLAN_ID="${PLAN_ID}" sh "${_attest}" >/dev/null 2>&1 || true
_attest="${SCRIPT_DIR}/${ATTESTATION_COMMAND}"
if [ ! -f "${_attest}" ]; then
ATTESTATION_REASON="${ATTESTATION_COMMAND} was not found beside init-session.sh"
return 0
fi
if [ ! -f "${_mode_plan}" ]; then
return 0
fi
if [ "$SLUG_MODE" -eq 1 ]; then
if _attest_output="$(PWF_PLAN_ROOT="$PWD" PLAN_ID="${PLAN_ID}" sh "${_attest}" 2>&1)"; then
ATTESTATION_OK=1
ATTESTATION_REASON=""
return 0
else
PWF_PLAN_ROOT="" PLAN_ID="" sh "${_attest}" >/dev/null 2>&1 || true
_attest_rc=$?
fi
else
if _attest_output="$(PWF_PLAN_ROOT="" PLAN_ID="" sh "${_attest}" 2>&1)"; then
ATTESTATION_OK=1
ATTESTATION_REASON=""
return 0
else
_attest_rc=$?
fi
fi
_attest_reason="$(
printf '%s\n' "${_attest_output}" |
sed -n '/[^[:space:]]/ { s/[[:space:]][[:space:]]*/ /g; s/^ //; s/ $//; p; q; }' |
cut -c1-300
)"
if [ -n "${_attest_reason}" ]; then
ATTESTATION_REASON="${_attest_reason}"
else
ATTESTATION_REASON="${ATTESTATION_COMMAND} exited with code ${_attest_rc}"
fi
return 0
}
print_v3_mode_status() {
_status_dir="$1"
_marker="$(cat "${_status_dir}/.mode")"
if [ "${ATTESTATION_OK:-0}" -eq 1 ]; then
printf 'Mode: %s (attested, gate counter reset)\n' "${_marker}"
else
printf 'Mode: %s (NOT attested: %s; run %s before the first hook fire)\n' \
"${_marker}" "${ATTESTATION_REASON:-attestation failed}" "${ATTESTATION_COMMAND:-attest-plan.sh}"
fi
}
@@ -431,7 +476,7 @@ if [ "$SLUG_MODE" -eq 1 ]; then
echo "Pin this terminal to the plan for parallel sessions:"
echo " export PLAN_ID=$PLAN_ID"
if [ -n "$MODE" ]; then
echo "Mode: $(cat "${PLAN_DIR}/.mode") (attested, gate counter reset)"
print_v3_mode_status "${PLAN_DIR}"
fi
else
PROJECT_NAME="${PROJECT_NAME:-project}"
@@ -442,6 +487,6 @@ else
echo "Planning files initialized!"
echo "Files: task_plan.md, findings.md, progress.md"
if [ -n "$MODE" ]; then
echo "Mode: $(cat "$(pwd)/.mode") (attested, gate counter reset)"
print_v3_mode_status "$(pwd)"
fi
fi
@@ -468,10 +468,13 @@ def _opencode_state_annotation(state: Any) -> str:
def _format_opencode_part(data: Dict[str, Any], session_id: str) -> Optional[Dict[str, Any]]:
"""Print-ready summary for one OpenCode part row."""
if not isinstance(data, dict):
return None
ptype = data.get('type')
short = safe_session_label(session_id)
if ptype == 'tool':
tool = (data.get('tool') or '').lower()
tool_value = data.get('tool')
tool = tool_value.lower() if isinstance(tool_value, str) else ''
state = data.get('state') or {}
input_ = state.get('input') if isinstance(state, dict) else None
input_ = input_ if isinstance(input_, dict) else {}
@@ -486,7 +489,8 @@ def _format_opencode_part(data: Dict[str, Any], session_id: str) -> Optional[Dic
return {'session': short, 'summary': f"Tool bash: {cmd}{outcome}"}
return {'session': short, 'summary': f"Tool {tool}{outcome}"}
if ptype == 'text':
text = (data.get('text') or '')[:300]
text_value = data.get('text')
text = text_value[:300] if isinstance(text_value, str) else ''
if text.strip():
return {'session': short, 'summary': f"text: {text}"}
return None
@@ -128,9 +128,58 @@ function Test-SafeActiveFile {
$linked = ([string]$item.LinkType) -in @('SymbolicLink', 'Junction')
return -not $item.PSIsContainer -and
($AllowLink -or -not $linked) -and
($AllowLink -or -not $item.IsReadOnly) -and
(Test-WithinRoot $ActiveFile)
}
function Test-SafeOwnedBackupFile {
param([string]$Path)
$item = Get-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue
if (-not $item) { return $false }
$linked = ([string]$item.LinkType) -in @('SymbolicLink', 'Junction')
return -not $item.PSIsContainer -and -not $linked -and (Test-WithinRoot $Path)
}
function Resolve-OwnedPointerBackup {
param([string]$BackupFile, [switch]$ReplaceSucceeded)
# ReplaceFile can move the old destination before failing to move the new
# file. A caller-named backup makes that intermediate state attributable to
# this invocation, so recover or delete only this GUID path. Never glob for
# ~RF*.TMP: a concurrent selector may own those files.
$maxAttempts = 20
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
$backupItem = Get-Item -LiteralPath $BackupFile -Force -ErrorAction SilentlyContinue
if (-not $backupItem) { return $true }
# A failed inspection is transient under concurrent writers: retry.
if (-not (Test-SafeOwnedBackupFile $BackupFile)) { Start-Sleep -Milliseconds 25; continue }
$currentActive = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
# After our own successful replacement the backup holds a superseded
# value: only delete it, never move it back over a newer pointer.
if ($ReplaceSucceeded -or $currentActive) {
if (-not $ReplaceSucceeded -and -not (Test-SafeActiveFile)) { Start-Sleep -Milliseconds 25; continue }
try {
[IO.File]::Delete($BackupFile)
return $true
} catch {
if ($attempt -eq $maxAttempts) { return $false }
}
} else {
try {
[IO.File]::Move($BackupFile, $ActiveFile)
return $true
} catch {
# Another selector may have recreated the pointer between the
# absence check and Move. Reinspect before deciding whether the
# owned backup is now redundant.
if ($attempt -eq $maxAttempts) { return $false }
}
}
Start-Sleep -Milliseconds 25
}
return $false
}
function Get-CurrentActivePlan {
if (-not (Test-SafeActiveFile -AllowLink)) { return "" }
try {
@@ -305,9 +354,16 @@ if ($activeItem -and -not (Test-SafeActiveFile)) {
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
# Replace the directory entry instead of truncating an existing inode: a
# hardlinked pointer must not overwrite another file. The exclusive temporary
# file lives beside the pointer, keeping replacement on the same filesystem.
$tempFile = Join-Path $PlanRoot ('.active_plan.' + [guid]::NewGuid().ToString('N') + '.tmp')
# file and caller-owned backup live beside the pointer, keeping replacement on
# the same filesystem. Naming the backup prevents ReplaceFile from inventing
# an unowned ~RF*.TMP path if its final rename fails.
$operationId = [guid]::NewGuid().ToString('N')
$tempFile = Join-Path $PlanRoot ('.active_plan.' + $operationId + '.tmp')
$backupFile = Join-Path $PlanRoot ('.active_plan.' + $operationId + '.replace-backup')
$createdTemp = $false
$failureMessage = ""
$backupResolved = $true
$replaced = $false
try {
$stream = [IO.File]::Open($tempFile, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
$createdTemp = $true
@@ -316,19 +372,48 @@ try {
$stream.Write($bytes, 0, $bytes.Length)
$stream.Flush()
} finally { $stream.Dispose() }
if ($activeItem) {
[IO.File]::Replace($tempFile, $ActiveFile, [NullString]::Value)
} else {
[IO.File]::Move($tempFile, $ActiveFile)
$maxAttempts = 20
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
try {
$currentActive = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
if ($currentActive) {
if (-not (Test-SafeActiveFile)) {
throw [InvalidOperationException]::new("the active plan pointer became unsafe during replacement")
}
[IO.File]::Replace($tempFile, $ActiveFile, $backupFile)
} else {
[IO.File]::Move($tempFile, $ActiveFile)
}
$replaced = $true
break
} catch [IO.IOException] {
if (-not (Resolve-OwnedPointerBackup -BackupFile $backupFile)) {
throw [IO.IOException]::new("could not safely recover the active plan pointer after replacement failure")
}
if ($attempt -eq $maxAttempts) { throw }
Start-Sleep -Milliseconds 25
}
}
} catch {
Write-Error "Error: could not set the active plan pointer: $($_.Exception.Message)"
exit 1
$failureMessage = $_.Exception.Message
} finally {
if (-not (Resolve-OwnedPointerBackup -BackupFile $backupFile -ReplaceSucceeded:$replaced)) {
# The pointer was written; a leftover backup is clutter, not failure.
if ($replaced) { Write-Warning "could not remove the replacement backup $backupFile" }
else { $backupResolved = $false }
}
if ($createdTemp -and (Test-Path -LiteralPath $tempFile)) {
Remove-Item -LiteralPath $tempFile -Force -ErrorAction SilentlyContinue
}
}
if (-not $backupResolved) {
Write-Error "Error: could not safely recover the active plan pointer backup."
exit 1
}
if ($failureMessage) {
Write-Error "Error: could not set the active plan pointer: $failureMessage"
exit 1
}
Write-Output "Active plan set to: $PlanId"
Write-Output "Path: $PlanDir"
Write-Output ""
@@ -8,6 +8,12 @@ State the analytical question or intended deliverable in one clear sentence.
[One sentence describing the analytical objective]
## Next Step
Record the single analytical action that should happen next. Update it whenever the active phase or immediate action changes.
[The single next analytical action. Update whenever phase status changes.]
## Current Phase
Name the phase currently being worked on.
@@ -76,6 +82,6 @@ Record each distinct error, the attempt number, and the resolution. Change the a
## Notes
- Update phase status as work progresses: `pending` to `in_progress` to `complete`.
- Re-read the goal and current phase before major analytical decisions.
- Re-read the goal, next step, and current phase before major analytical decisions.
- Log errors promptly so failed approaches are not repeated.
- Record query results and visual evidence in findings.md.
Regular → Executable
+1 -1
View File
@@ -12,5 +12,5 @@ if [ ! -f "$PLAN_FILE" ]; then
exit 0
fi
echo '{"additionalContext":"[planning-with-files] Update progress.md with what you just did. If a phase is now complete, update task_plan.md status."}'
echo '{"hookSpecificOutput":{"additionalContext":"[planning-with-files] Update progress.md with what you just did. If a phase is now complete, update task_plan.md status."}}'
exit 0
+3 -3
View File
@@ -1,6 +1,6 @@
#!/bin/bash
# planning-with-files: BeforeTool hook for Gemini CLI
# Reads the first 30 lines of task_plan.md before tool use.
# planning-with-files: BeforeAgent hook for Gemini CLI
# Injects task_plan.md context into the user prompt once per turn.
# Receives JSON on stdin, must output ONLY JSON to stdout.
INPUT=$(cat)
@@ -23,7 +23,7 @@ PYTHON=$(command -v python3 || command -v python)
if [ -n "$PYTHON" ]; then
ESCAPED=$($PYTHON -I -X utf8 -c "import sys,json; print(json.dumps(sys.stdin.read(), ensure_ascii=False))" <<< "$CONTEXT" 2>/dev/null)
if [ -n "$ESCAPED" ] && [ "$ESCAPED" != "\"\"" ]; then
printf '%s\n' "{\"systemMessage\":$ESCAPED}"
printf '%s\n' "{\"hookSpecificOutput\":{\"additionalContext\":$ESCAPED}}"
exit 0
fi
fi
-27
View File
@@ -1,27 +0,0 @@
#!/bin/bash
# planning-with-files: BeforeModel hook for Gemini CLI
# Injects plan awareness before every model call.
# This is UNIQUE to Gemini CLI — no other IDE has a BeforeModel event.
# Reads stdin JSON, outputs JSON to stdout.
INPUT=$(cat)
PLAN_FILE="task_plan.md"
if [ ! -f "$PLAN_FILE" ]; then
echo '{}'
exit 0
fi
# Only inject a lightweight reminder (not the full plan — that's BeforeTool's job)
CURRENT_PHASE=$(grep -m1 "^## Current Phase" "$PLAN_FILE" 2>/dev/null || grep -m1 "in_progress" "$PLAN_FILE" 2>/dev/null || echo "")
if [ -n "$CURRENT_PHASE" ]; then
PYTHON=$(command -v python3 || command -v python)
ESCAPED=$($PYTHON -I -X utf8 -c "import sys,json; print(json.dumps(sys.stdin.read(), ensure_ascii=False))" <<< "[planning-with-files] Current: $CURRENT_PHASE" 2>/dev/null || echo "\"\"")
printf '%s\n' "{\"additionalContext\":$ESCAPED}"
else
echo '{}'
fi
exit 0
Regular → Executable
View File
Regular → Executable
View File
+4 -18
View File
@@ -13,16 +13,15 @@
]
}
],
"BeforeTool": [
"BeforeAgent": [
{
"matcher": "write_file|edit_file|replace|create_file|patch|read_file|shell",
"hooks": [
{
"name": "planning-before-tool",
"name": "planning-before-agent",
"type": "command",
"command": "$GEMINI_PROJECT_DIR/.gemini/hooks/before-tool.sh",
"command": "$GEMINI_PROJECT_DIR/.gemini/hooks/before-agent.sh",
"timeout": 5000,
"description": "Read plan context before tool use to keep goals in attention"
"description": "Inject task_plan.md context into the user turn"
}
]
}
@@ -41,19 +40,6 @@
]
}
],
"BeforeModel": [
{
"hooks": [
{
"name": "planning-before-model",
"type": "command",
"command": "$GEMINI_PROJECT_DIR/.gemini/hooks/before-model.sh",
"timeout": 5000,
"description": "Inject plan awareness before every model call"
}
]
}
],
"SessionEnd": [
{
"hooks": [
+1 -1
View File
@@ -3,7 +3,7 @@ name: planning-with-files
description: "Persistent file-based planning for multi-step AI-agent work. Keeps task_plan.md, findings.md, and progress.md on disk; Gemini lifecycle hooks inject selected project planning context. Automatic recovery reads project planning files only. Explicit session-catchup.py --metadata reads same-project local agent session records and emits aggregate counts only; --replay may emit bounded nonce-framed excerpts. The session-end hook reports status only; it does not request continuation or run commands declared in Markdown. The skill has no network upload path. Use for research or work needing 5+ tool calls."
metadata:
version: "2.43.0"
hooks: "Configured in .gemini/settings.json (SessionStart, BeforeTool, AfterTool, BeforeModel)"
hooks: "Configured in .gemini/settings.json (SessionStart, BeforeAgent, AfterTool, SessionEnd)"
---
# Planning with Files
@@ -6,16 +6,19 @@
# attested hash, surfacing a "[PLAN TAMPERED]" warning instead.
#
# Resolution:
# 1. $PLAN_ID env var → ./.planning/$PLAN_ID/
# 2. ./.planning/.active_plan
# 3. Newest ./.planning/<dir>/ by mtime
# 4. Current directory when it is .planning/<valid-slug>/
# 5. Legacy ./task_plan.md at project root
# 1. --target root or <plan-id> → root or named plan under $PWF_PLAN_ROOT (default: cwd)
# 2. $PLAN_ID env var → ./.planning/$PLAN_ID/
# 3. ./.planning/.active_plan
# 4. Newest ./.planning/<dir>/ by mtime
# 5. Current directory when it is .planning/<valid-slug>/
# 6. Legacy ./task_plan.md at project root
#
# Usage:
# sh scripts/attest-plan.sh # attest the active plan
# sh scripts/attest-plan.sh --show # print the stored hash
# sh scripts/attest-plan.sh --clear # remove the attestation (re-open the plan)
# sh scripts/attest-plan.sh --target root # attest the legacy root plan
# sh scripts/attest-plan.sh --target <plan-id> # attest one named plan
set -u
@@ -42,6 +45,39 @@ resolve_from_slug_cwd() {
}
resolve_plan_file() {
if [ -n "${target:-}" ]; then
if [ "${target}" = "root" ]; then
target_root="."
if [ -n "${PWF_PLAN_ROOT:-}" ]; then
# Match the shared resolver's supported absolute local pins.
# A valid project-root junction is intentional, so resolve it.
case "${PWF_PLAN_ROOT}" in
\\\\*|//*|[A-Za-z]:[!\\/]*) return 1 ;;
/*|[A-Za-z]:[\\/]*) ;;
*) return 1 ;;
esac
target_root="$(cd "${PWF_PLAN_ROOT}" 2>/dev/null && pwd -P)" || return 1
fi
[ ! -L "${target_root}/task_plan.md" ] || return 1
[ -f "${target_root}/task_plan.md" ] || return 1
printf "%s\n" "${target_root}/task_plan.md"
return 0
fi
slug_is_valid "${target}" || return 1
# Reuse the binding, project-pin and containment rules. A flag replaces
# PLAN_ID for this call only; it does not change the shared pointer.
[ -f "${RESOLVER}" ] || return 1
target_dir="$(PLAN_ID="${target}" sh "${RESOLVER}" 2>/dev/null)" || return 1
[ -n "${target_dir}" ] || return 1
# Use the shell's physical path spelling: native Windows backslashes
# make sha256sum prefix its output with an escaped-filename marker.
target_dir="$(cd "${target_dir}" 2>/dev/null && pwd -P)" || return 1
[ ! -L "${target_dir}/task_plan.md" ] || return 1
[ -f "${target_dir}/task_plan.md" ] || return 1
printf "%s\n" "${target_dir}/task_plan.md"
return 0
fi
plan_dir=""
if [ -f "${RESOLVER}" ]; then
plan_dir="$(sh "${RESOLVER}" 2>/dev/null)"
@@ -80,7 +116,9 @@ resolve_plan_file() {
attestation_path_for() {
plan_file="$1"
plan_dir="$(dirname "${plan_file}")"
if [ "${plan_dir}" = "." ]; then
if [ "${target:-}" = "root" ]; then
printf "%s\n" "${plan_dir}/.plan-attestation"
elif [ "${plan_dir}" = "." ]; then
# Legacy mode: store at project root.
printf "%s\n" "./.plan-attestation"
else
@@ -101,14 +139,21 @@ compute_hash() {
}
mode="attest"
target=""
usage_error() {
printf "Usage: %s [--show|--clear|--target root|<plan-id>]\n" "$0" >&2
exit 2
}
case "${1:-}" in
--show) mode="show" ;;
--clear) mode="clear" ;;
"") mode="attest" ;;
*)
printf "Usage: %s [--show|--clear]\n" "$0" >&2
exit 2
--show) [ "$#" -eq 1 ] || usage_error; mode="show" ;;
--clear) [ "$#" -eq 1 ] || usage_error; mode="clear" ;;
--target)
[ "$#" -eq 2 ] || usage_error
[ -n "$2" ] || usage_error
target="$2"
;;
"") [ "$#" -eq 0 ] || usage_error ;;
*) usage_error ;;
esac
plan_file="$(resolve_plan_file)" || {
@@ -117,7 +162,9 @@ plan_file="$(resolve_plan_file)" || {
# a mistyped PLAN_ID attested a DIFFERENT plan at rc=0, and an operator
# who now sees a generic not-found is likely to go looking for the wrong
# problem. The selectors are bindings, so say which one refused.
if [ -n "${PLAN_ID:-}" ]; then
if [ -n "${target:-}" ]; then
printf "[plan-attest] --target %s did not resolve to a plan. An explicit target is a binding: nothing was attested and no other plan was substituted.\n" "${target}" >&2
elif [ -n "${PLAN_ID:-}" ]; then
printf "[plan-attest] PLAN_ID=%s names no plan directory under .planning. An explicit selector is a binding: nothing was attested and no other plan was substituted.\n" "${PLAN_ID}" >&2
elif [ -n "${PWF_PLAN_ROOT:-}" ]; then
printf "[plan-attest] PWF_PLAN_ROOT=%s did not resolve to a project root holding a plan. An explicit pin is a binding: nothing was attested and no other plan was substituted.\n" "${PWF_PLAN_ROOT}" >&2
@@ -156,6 +203,8 @@ case "${mode}" in
fi
;;
attest)
printf "Plan: %s\n" "${plan_file}"
printf "Attestation: %s\n" "${attestation_file}"
hash_val="$(compute_hash "${plan_file}")" || exit 1
# v2.40: protect the write with an advisory flock when available so
@@ -51,6 +51,8 @@ if ($PlanFile -ne "") {
}
if (-not (Test-Path $PlanFile)) {
# Automatic gate checks have nothing to report without a plan; the explicit report keeps its notice.
if ($Gate) { exit 0 }
Write-Host '[planning-with-files] No task_plan.md found -- no active planning session.'
exit 0
}
@@ -79,6 +79,9 @@ else
fi
if [ ! -f "$PLAN_FILE" ]; then
# Automatic gate checks have nothing to report without a plan (header:
# "plans stay silent in --gate mode"); the explicit report keeps its notice.
[ "$GATE" -eq 1 ] && exit 0
echo "[planning-with-files] No task_plan.md found — no active planning session."
exit 0
fi
@@ -18,6 +18,27 @@ param(
[switch]$Gated
)
# Windows PowerShell 5.1 can silently relocate a -File invocation when the
# inherited cwd contains wildcard characters such as [ or ]. Recover the
# physical cwd for a direct invocation before any project-relative paths are
# resolved.
if ($PSVersionTable.PSVersion.Major -eq 5 -and
[System.Management.Automation.WildcardPattern]::ContainsWildcardCharacters([Environment]::CurrentDirectory)) {
$processArgs = [Environment]::GetCommandLineArgs()
for ($index = 0; $index -lt ($processArgs.Length - 1); $index++) {
if ($processArgs[$index] -ieq '-File') {
$entryScript = $processArgs[$index + 1]
if (-not [IO.Path]::IsPathRooted($entryScript)) {
$entryScript = Join-Path ([Environment]::CurrentDirectory) $entryScript
}
if ([IO.Path]::GetFullPath($entryScript) -eq [IO.Path]::GetFullPath($PSCommandPath)) {
Set-Location -LiteralPath ([Environment]::CurrentDirectory)
}
break
}
}
}
$DATE = Get-Date -Format "yyyy-MM-dd"
# Resolve v3 opt-in mode. -Gated implies autonomous and is the stronger marker.
@@ -80,6 +101,26 @@ function Get-InheritedMode([string]$CurrentMode) {
return $CurrentMode
}
function Format-AttestationFailureReason {
param([object[]]$Output, [string]$Fallback)
$Reason = @(
$Output |
ForEach-Object {
if ($null -ne $_) { ($_.ToString()).Trim() }
} |
Where-Object { $_ }
) -join " "
$Reason = ($Reason -replace '\s+', ' ').Trim()
if ([string]::IsNullOrWhiteSpace($Reason)) {
return $Fallback
}
if ($Reason.Length -gt 300) {
return ($Reason.Substring(0, 297) + "...")
}
return $Reason
}
# Validate template
if ($Template -ne "default" -and $Template -ne "analytics") {
Write-Host "Unknown template: $Template (available: default, analytics). Using default."
@@ -136,20 +177,7 @@ if ($UsePlanDir) {
$Counter++
}
$TargetDir = Join-Path $PlanningRoot $PlanId
New-Item -ItemType Directory -Path $TargetDir -Force | Out-Null
# Reuse the selector's contained, atomic pointer replacement. Set-Content
# would follow a reparse point and truncate a hardlinked pointer in place,
# overwriting whichever file shares that inode.
$global:LASTEXITCODE = 0
try {
& $PlanSelector $PlanId *> $null
} catch {
$global:LASTEXITCODE = 1
}
if ($LASTEXITCODE -ne 0) {
Write-Error "Error: could not safely update the active plan pointer at $(Join-Path $PlanningRoot '.active_plan')."
exit 1
}
New-Item -ItemType Directory -Path $TargetDir -Force -ErrorAction Stop | Out-Null
$Mode = Get-InheritedMode $Mode
} else {
$TargetDir = (Get-Location).Path
@@ -173,11 +201,12 @@ if ($UsePlanDir) {
Write-Host "PLAN_ID=$PlanId"
}
try {
# Create task_plan.md if it doesn't exist
if (-not (Test-Path -LiteralPath $TaskPlanPath)) {
$AnalyticsPlan = Join-Path $TemplateDir "analytics_task_plan.md"
if ($Template -eq "analytics" -and (Test-Path $AnalyticsPlan)) {
Copy-Item -LiteralPath $AnalyticsPlan -Destination $TaskPlanPath
Copy-Item -LiteralPath $AnalyticsPlan -Destination $TaskPlanPath -ErrorAction Stop
} else {
@"
# Task Plan: [Brief Description]
@@ -226,7 +255,7 @@ Phase 1
## Errors Encountered
| Error | Resolution |
|-------|------------|
"@ | Out-File -LiteralPath $TaskPlanPath -Encoding UTF8
"@ | Out-File -LiteralPath $TaskPlanPath -Encoding UTF8 -ErrorAction Stop
}
Write-Host "Created $TaskPlanDisplay"
} else {
@@ -237,7 +266,7 @@ Phase 1
if (-not (Test-Path -LiteralPath $FindingsPath)) {
$AnalyticsFindings = Join-Path $TemplateDir "analytics_findings.md"
if ($Template -eq "analytics" -and (Test-Path $AnalyticsFindings)) {
Copy-Item -LiteralPath $AnalyticsFindings -Destination $FindingsPath
Copy-Item -LiteralPath $AnalyticsFindings -Destination $FindingsPath -ErrorAction Stop
} else {
@"
# Findings & Decisions
@@ -258,7 +287,7 @@ if (-not (Test-Path -LiteralPath $FindingsPath)) {
## Resources
-
"@ | Out-File -LiteralPath $FindingsPath -Encoding UTF8
"@ | Out-File -LiteralPath $FindingsPath -Encoding UTF8 -ErrorAction Stop
}
Write-Host "Created $FindingsDisplay"
} else {
@@ -287,7 +316,7 @@ if (-not (Test-Path -LiteralPath $ProgressPath)) {
### Errors
| Error | Resolution |
|-------|------------|
"@ | Out-File -LiteralPath $ProgressPath -Encoding UTF8
"@ | Out-File -LiteralPath $ProgressPath -Encoding UTF8 -ErrorAction Stop
} else {
@"
# Progress Log
@@ -308,12 +337,58 @@ if (-not (Test-Path -LiteralPath $ProgressPath)) {
### Errors
| Error | Resolution |
|-------|------------|
"@ | Out-File -LiteralPath $ProgressPath -Encoding UTF8
"@ | Out-File -LiteralPath $ProgressPath -Encoding UTF8 -ErrorAction Stop
}
Write-Host "Created $ProgressDisplay"
} else {
Write-Host "$ProgressDisplay already exists, skipping"
}
} catch {
Write-Error "Error: could not initialize planning files in '$TargetDir': $($_.Exception.Message)"
exit 1
}
if ($UsePlanDir) {
# Activate the named plan only after all three planning files are ready.
# This matches init-session.sh and prevents a failed initialization from
# leaving .active_plan pointed at a partial plan directory.
$pointerSet = $false
for ($attempt = 1; $attempt -le 5; $attempt++) {
$global:LASTEXITCODE = 0
try {
$selectorResult = & $PlanSelector $PlanId 2>&1
} catch {
$selectorResult = $_
$global:LASTEXITCODE = 1
}
if ($LASTEXITCODE -eq 0) {
$pointerSet = $true
break
}
# Another writer can replace the pointer between the selector's
# Get-Item and final-path check. Retry only that transient result.
$transientPointerRace = $selectorResult -is [System.Management.Automation.ErrorRecord] -and
$selectorResult.Exception.Message -in @(
'Error: could not set the active plan pointer: the active plan pointer became unsafe during replacement',
'Error: the active plan pointer must be a regular file within the project.'
)
if ($attempt -eq 5 -or -not $transientPointerRace) {
break
}
Start-Sleep -Milliseconds 50
$global:LASTEXITCODE = 0
try {
& $PlanSelector -VerifyRoot *> $null
} catch {
$global:LASTEXITCODE = 1
}
if ($LASTEXITCODE -ne 0) { break }
}
if (-not $pointerSet) {
Write-Error "Error: could not safely update the active plan pointer at $(Join-Path $PlanningRoot '.active_plan')."
exit 1
}
}
Write-Host ""
Write-Host "Planning files initialized!"
@@ -355,8 +430,11 @@ if ($Mode -ne "") {
# to another project or plan (#261, #237). Root mode clears both instead:
# the attester only falls back to the legacy ./task_plan.md when no
# selector is set, and a bound pin would make it refuse the root plan.
$AttestationSucceeded = $false
$AttestationCommand = "attest-plan"
$AttestationReason = "task_plan.md was not available for attestation"
$PlanFilePwf = Join-Path $PlanDirPwf "task_plan.md"
if (Test-Path -LiteralPath $PlanFilePwf) {
if (Test-Path -LiteralPath $PlanFilePwf -PathType Leaf) {
$HadPlanId = Test-Path Env:PLAN_ID
$PreviousPlanId = $env:PLAN_ID
$HadPlanRoot = Test-Path Env:PWF_PLAN_ROOT
@@ -370,21 +448,54 @@ if ($Mode -ne "") {
Remove-Item Env:PLAN_ID -ErrorAction SilentlyContinue
}
# A called script can return without changing $LASTEXITCODE, so
# clear the inherited value before every attempt. Both a non-zero
# status and a terminating exception mean the plan is not attested.
$global:LASTEXITCODE = 0
$AttestOutput = @()
$IsWindowsHost = [Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT
if ($IsWindowsHost) {
$AttestPs1 = Join-Path $ScriptDir "attest-plan.ps1"
if (Test-Path -LiteralPath $AttestPs1) {
& $AttestPs1 *> $null
$AttestationCommand = "attest-plan.ps1"
$AttestPs1 = Join-Path $ScriptDir $AttestationCommand
if (Test-Path -LiteralPath $AttestPs1 -PathType Leaf) {
$AttestOutput = @(& $AttestPs1 2>&1)
$AttestExitCode = $LASTEXITCODE
if ($AttestExitCode -eq 0) {
$AttestationSucceeded = $true
$AttestationReason = ""
} else {
$AttestationReason = Format-AttestationFailureReason `
-Output $AttestOutput `
-Fallback "$AttestationCommand exited with code $AttestExitCode"
}
} else {
$AttestationReason = "$AttestationCommand was not found beside init-session.ps1"
}
} else {
$AttestSh = Join-Path $ScriptDir "attest-plan.sh"
$AttestationCommand = "attest-plan.sh"
$AttestSh = Join-Path $ScriptDir $AttestationCommand
$Sh = Get-Command sh -ErrorAction SilentlyContinue
if ($Sh -and (Test-Path -LiteralPath $AttestSh)) {
& $Sh.Path $AttestSh *> $null
if ($Sh -and (Test-Path -LiteralPath $AttestSh -PathType Leaf)) {
$AttestOutput = @(& $Sh.Path $AttestSh 2>&1)
$AttestExitCode = $LASTEXITCODE
if ($AttestExitCode -eq 0) {
$AttestationSucceeded = $true
$AttestationReason = ""
} else {
$AttestationReason = Format-AttestationFailureReason `
-Output $AttestOutput `
-Fallback "$AttestationCommand exited with code $AttestExitCode"
}
} elseif (-not $Sh) {
$AttestationReason = "sh was not found; $AttestationCommand could not run"
} else {
$AttestationReason = "$AttestationCommand was not found beside init-session.ps1"
}
}
} catch {
# attestation failure must not abort init; the mode marker still stands.
$AttestationReason = Format-AttestationFailureReason `
-Output @($_) `
-Fallback "$AttestationCommand failed"
} finally {
if ($HadPlanId) {
$env:PLAN_ID = $PreviousPlanId
@@ -399,5 +510,9 @@ if ($Mode -ne "") {
}
}
Write-Host "Mode: $MarkerText (attested, gate counter reset)"
if ($AttestationSucceeded) {
Write-Host "Mode: $MarkerText (attested, gate counter reset)"
} else {
Write-Host "Mode: $MarkerText (NOT attested: $AttestationReason; run $AttestationCommand before the first hook fire)"
}
}
@@ -186,6 +186,10 @@ apply_v3_mode() {
_mode_plan="$2"
[ -z "$MODE" ] && return 0
ATTESTATION_OK=0
ATTESTATION_COMMAND="attest-plan.sh"
ATTESTATION_REASON="task_plan.md was not available for attestation"
# (a) reset the gate block counter and drop any stale gate ledger so a prior
# run's high block count cannot let the next run stop instantly.
printf '0\n' > "${_mode_dir}/.stop_blocks"
@@ -210,13 +214,54 @@ apply_v3_mode() {
# ./task_plan.md when no selector is set, and a bound pin would make it
# refuse the root plan. Run from the project root (CWD here) so both
# resolutions land.
_attest="${SCRIPT_DIR}/attest-plan.sh"
if [ -f "${_attest}" ] && [ -f "${_mode_plan}" ]; then
if [ "$SLUG_MODE" -eq 1 ]; then
PWF_PLAN_ROOT="$PWD" PLAN_ID="${PLAN_ID}" sh "${_attest}" >/dev/null 2>&1 || true
_attest="${SCRIPT_DIR}/${ATTESTATION_COMMAND}"
if [ ! -f "${_attest}" ]; then
ATTESTATION_REASON="${ATTESTATION_COMMAND} was not found beside init-session.sh"
return 0
fi
if [ ! -f "${_mode_plan}" ]; then
return 0
fi
if [ "$SLUG_MODE" -eq 1 ]; then
if _attest_output="$(PWF_PLAN_ROOT="$PWD" PLAN_ID="${PLAN_ID}" sh "${_attest}" 2>&1)"; then
ATTESTATION_OK=1
ATTESTATION_REASON=""
return 0
else
PWF_PLAN_ROOT="" PLAN_ID="" sh "${_attest}" >/dev/null 2>&1 || true
_attest_rc=$?
fi
else
if _attest_output="$(PWF_PLAN_ROOT="" PLAN_ID="" sh "${_attest}" 2>&1)"; then
ATTESTATION_OK=1
ATTESTATION_REASON=""
return 0
else
_attest_rc=$?
fi
fi
_attest_reason="$(
printf '%s\n' "${_attest_output}" |
sed -n '/[^[:space:]]/ { s/[[:space:]][[:space:]]*/ /g; s/^ //; s/ $//; p; q; }' |
cut -c1-300
)"
if [ -n "${_attest_reason}" ]; then
ATTESTATION_REASON="${_attest_reason}"
else
ATTESTATION_REASON="${ATTESTATION_COMMAND} exited with code ${_attest_rc}"
fi
return 0
}
print_v3_mode_status() {
_status_dir="$1"
_marker="$(cat "${_status_dir}/.mode")"
if [ "${ATTESTATION_OK:-0}" -eq 1 ]; then
printf 'Mode: %s (attested, gate counter reset)\n' "${_marker}"
else
printf 'Mode: %s (NOT attested: %s; run %s before the first hook fire)\n' \
"${_marker}" "${ATTESTATION_REASON:-attestation failed}" "${ATTESTATION_COMMAND:-attest-plan.sh}"
fi
}
@@ -431,7 +476,7 @@ if [ "$SLUG_MODE" -eq 1 ]; then
echo "Pin this terminal to the plan for parallel sessions:"
echo " export PLAN_ID=$PLAN_ID"
if [ -n "$MODE" ]; then
echo "Mode: $(cat "${PLAN_DIR}/.mode") (attested, gate counter reset)"
print_v3_mode_status "${PLAN_DIR}"
fi
else
PROJECT_NAME="${PROJECT_NAME:-project}"
@@ -442,6 +487,6 @@ else
echo "Planning files initialized!"
echo "Files: task_plan.md, findings.md, progress.md"
if [ -n "$MODE" ]; then
echo "Mode: $(cat "$(pwd)/.mode") (attested, gate counter reset)"
print_v3_mode_status "$(pwd)"
fi
fi
@@ -468,10 +468,13 @@ def _opencode_state_annotation(state: Any) -> str:
def _format_opencode_part(data: Dict[str, Any], session_id: str) -> Optional[Dict[str, Any]]:
"""Print-ready summary for one OpenCode part row."""
if not isinstance(data, dict):
return None
ptype = data.get('type')
short = safe_session_label(session_id)
if ptype == 'tool':
tool = (data.get('tool') or '').lower()
tool_value = data.get('tool')
tool = tool_value.lower() if isinstance(tool_value, str) else ''
state = data.get('state') or {}
input_ = state.get('input') if isinstance(state, dict) else None
input_ = input_ if isinstance(input_, dict) else {}
@@ -486,7 +489,8 @@ def _format_opencode_part(data: Dict[str, Any], session_id: str) -> Optional[Dic
return {'session': short, 'summary': f"Tool bash: {cmd}{outcome}"}
return {'session': short, 'summary': f"Tool {tool}{outcome}"}
if ptype == 'text':
text = (data.get('text') or '')[:300]
text_value = data.get('text')
text = text_value[:300] if isinstance(text_value, str) else ''
if text.strip():
return {'session': short, 'summary': f"text: {text}"}
return None
@@ -128,9 +128,58 @@ function Test-SafeActiveFile {
$linked = ([string]$item.LinkType) -in @('SymbolicLink', 'Junction')
return -not $item.PSIsContainer -and
($AllowLink -or -not $linked) -and
($AllowLink -or -not $item.IsReadOnly) -and
(Test-WithinRoot $ActiveFile)
}
function Test-SafeOwnedBackupFile {
param([string]$Path)
$item = Get-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue
if (-not $item) { return $false }
$linked = ([string]$item.LinkType) -in @('SymbolicLink', 'Junction')
return -not $item.PSIsContainer -and -not $linked -and (Test-WithinRoot $Path)
}
function Resolve-OwnedPointerBackup {
param([string]$BackupFile, [switch]$ReplaceSucceeded)
# ReplaceFile can move the old destination before failing to move the new
# file. A caller-named backup makes that intermediate state attributable to
# this invocation, so recover or delete only this GUID path. Never glob for
# ~RF*.TMP: a concurrent selector may own those files.
$maxAttempts = 20
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
$backupItem = Get-Item -LiteralPath $BackupFile -Force -ErrorAction SilentlyContinue
if (-not $backupItem) { return $true }
# A failed inspection is transient under concurrent writers: retry.
if (-not (Test-SafeOwnedBackupFile $BackupFile)) { Start-Sleep -Milliseconds 25; continue }
$currentActive = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
# After our own successful replacement the backup holds a superseded
# value: only delete it, never move it back over a newer pointer.
if ($ReplaceSucceeded -or $currentActive) {
if (-not $ReplaceSucceeded -and -not (Test-SafeActiveFile)) { Start-Sleep -Milliseconds 25; continue }
try {
[IO.File]::Delete($BackupFile)
return $true
} catch {
if ($attempt -eq $maxAttempts) { return $false }
}
} else {
try {
[IO.File]::Move($BackupFile, $ActiveFile)
return $true
} catch {
# Another selector may have recreated the pointer between the
# absence check and Move. Reinspect before deciding whether the
# owned backup is now redundant.
if ($attempt -eq $maxAttempts) { return $false }
}
}
Start-Sleep -Milliseconds 25
}
return $false
}
function Get-CurrentActivePlan {
if (-not (Test-SafeActiveFile -AllowLink)) { return "" }
try {
@@ -305,9 +354,16 @@ if ($activeItem -and -not (Test-SafeActiveFile)) {
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
# Replace the directory entry instead of truncating an existing inode: a
# hardlinked pointer must not overwrite another file. The exclusive temporary
# file lives beside the pointer, keeping replacement on the same filesystem.
$tempFile = Join-Path $PlanRoot ('.active_plan.' + [guid]::NewGuid().ToString('N') + '.tmp')
# file and caller-owned backup live beside the pointer, keeping replacement on
# the same filesystem. Naming the backup prevents ReplaceFile from inventing
# an unowned ~RF*.TMP path if its final rename fails.
$operationId = [guid]::NewGuid().ToString('N')
$tempFile = Join-Path $PlanRoot ('.active_plan.' + $operationId + '.tmp')
$backupFile = Join-Path $PlanRoot ('.active_plan.' + $operationId + '.replace-backup')
$createdTemp = $false
$failureMessage = ""
$backupResolved = $true
$replaced = $false
try {
$stream = [IO.File]::Open($tempFile, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
$createdTemp = $true
@@ -316,19 +372,48 @@ try {
$stream.Write($bytes, 0, $bytes.Length)
$stream.Flush()
} finally { $stream.Dispose() }
if ($activeItem) {
[IO.File]::Replace($tempFile, $ActiveFile, [NullString]::Value)
} else {
[IO.File]::Move($tempFile, $ActiveFile)
$maxAttempts = 20
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
try {
$currentActive = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
if ($currentActive) {
if (-not (Test-SafeActiveFile)) {
throw [InvalidOperationException]::new("the active plan pointer became unsafe during replacement")
}
[IO.File]::Replace($tempFile, $ActiveFile, $backupFile)
} else {
[IO.File]::Move($tempFile, $ActiveFile)
}
$replaced = $true
break
} catch [IO.IOException] {
if (-not (Resolve-OwnedPointerBackup -BackupFile $backupFile)) {
throw [IO.IOException]::new("could not safely recover the active plan pointer after replacement failure")
}
if ($attempt -eq $maxAttempts) { throw }
Start-Sleep -Milliseconds 25
}
}
} catch {
Write-Error "Error: could not set the active plan pointer: $($_.Exception.Message)"
exit 1
$failureMessage = $_.Exception.Message
} finally {
if (-not (Resolve-OwnedPointerBackup -BackupFile $backupFile -ReplaceSucceeded:$replaced)) {
# The pointer was written; a leftover backup is clutter, not failure.
if ($replaced) { Write-Warning "could not remove the replacement backup $backupFile" }
else { $backupResolved = $false }
}
if ($createdTemp -and (Test-Path -LiteralPath $tempFile)) {
Remove-Item -LiteralPath $tempFile -Force -ErrorAction SilentlyContinue
}
}
if (-not $backupResolved) {
Write-Error "Error: could not safely recover the active plan pointer backup."
exit 1
}
if ($failureMessage) {
Write-Error "Error: could not set the active plan pointer: $failureMessage"
exit 1
}
Write-Output "Active plan set to: $PlanId"
Write-Output "Path: $PlanDir"
Write-Output ""
@@ -8,6 +8,12 @@ State the analytical question or intended deliverable in one clear sentence.
[One sentence describing the analytical objective]
## Next Step
Record the single analytical action that should happen next. Update it whenever the active phase or immediate action changes.
[The single next analytical action. Update whenever phase status changes.]
## Current Phase
Name the phase currently being worked on.
@@ -76,6 +82,6 @@ Record each distinct error, the attempt number, and the resolution. Change the a
## Notes
- Update phase status as work progresses: `pending` to `in_progress` to `complete`.
- Re-read the goal and current phase before major analytical decisions.
- Re-read the goal, next step, and current phase before major analytical decisions.
- Log errors promptly so failed approaches are not repeated.
- Record query results and visual evidence in findings.md.
@@ -115,6 +115,46 @@ def apply_v3_mode(project_dir: Path, plan_dir: Path, mode: str) -> dict[str, Any
return {"marker": marker.strip(), "attestation": digest}
def _active_plan_pointer_error(planning_root: Path) -> str | None:
pointer = planning_root / ".active_plan"
if not pointer.exists() and not pointer.is_symlink():
return None
try:
stat = pointer.lstat()
resolved = pointer.resolve(strict=True)
resolved.relative_to(planning_root.resolve(strict=True))
except (OSError, ValueError):
return f"refusing to replace unsafe active plan pointer: {pointer}"
if pointer.is_symlink() or not pointer.is_file() or stat.st_nlink > 1:
return f"refusing to replace unsafe active plan pointer: {pointer}"
return None
def _write_active_plan(planning_root: Path, plan_id: str) -> str | None:
pointer = planning_root / ".active_plan"
unsafe = _active_plan_pointer_error(planning_root)
if unsafe:
return unsafe
temp = planning_root / f".active_plan.{os.getpid()}.{secrets.token_hex(4)}.tmp"
fd: int | None = None
try:
fd = os.open(temp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as handle:
fd = None
handle.write(plan_id + "\n")
os.replace(temp, pointer)
return None
except OSError as exc:
return f"could not safely update active plan pointer: {exc}"
finally:
if fd is not None:
os.close(fd)
try:
temp.unlink()
except FileNotFoundError:
pass
def init_plan(
project_dir: Path,
*,
@@ -147,8 +187,13 @@ def init_plan(
planning_root.mkdir(parents=True, exist_ok=True)
plan_id = f"{_dt.date.today().isoformat()}-{slug}"
plan_dir = planning_root / plan_id
pointer_error = _active_plan_pointer_error(planning_root)
if pointer_error:
return {"ok": False, "error": pointer_error, "project_dir": str(project_dir)}
plan_dir.mkdir(exist_ok=True)
(planning_root / ".active_plan").write_text(plan_id + "\n", encoding="utf-8")
write_error = _write_active_plan(planning_root, plan_id)
if write_error:
return {"ok": False, "error": write_error, "project_dir": str(project_dir)}
else:
plan_dir = project_dir
plan_id = "root"
+1 -1
View File
@@ -2,7 +2,7 @@
name: planning-with-files
description: "Persistent file-based planning for multi-step AI-agent work. Keeps task_plan.md, findings.md, and progress.md on disk; lifecycle hooks inject selected project planning context. Automatic recovery reads project planning files only. Explicit session-catchup.py --metadata reads same-project local agent session records and emits aggregate counts only; --replay may emit bounded nonce-framed excerpts. Optional gated mode can request continuation only when the host supports it and never runs commands declared in Markdown. The skill has no network upload path. Use for research or work needing 5+ tool calls."
metadata:
version: "3.20.4"
version: "3.21.0"
hermes:
tags: [planning, long-running-tasks, context-engineering, workflow]
---
@@ -7,6 +7,8 @@ param(
[string]$Template = "default"
)
$ErrorActionPreference = "Stop"
$DATE = Get-Date -Format "yyyy-MM-dd"
# Resolve template directory (skill root is one level up from scripts/)
@@ -128,9 +128,58 @@ function Test-SafeActiveFile {
$linked = ([string]$item.LinkType) -in @('SymbolicLink', 'Junction')
return -not $item.PSIsContainer -and
($AllowLink -or -not $linked) -and
($AllowLink -or -not $item.IsReadOnly) -and
(Test-WithinRoot $ActiveFile)
}
function Test-SafeOwnedBackupFile {
param([string]$Path)
$item = Get-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue
if (-not $item) { return $false }
$linked = ([string]$item.LinkType) -in @('SymbolicLink', 'Junction')
return -not $item.PSIsContainer -and -not $linked -and (Test-WithinRoot $Path)
}
function Resolve-OwnedPointerBackup {
param([string]$BackupFile, [switch]$ReplaceSucceeded)
# ReplaceFile can move the old destination before failing to move the new
# file. A caller-named backup makes that intermediate state attributable to
# this invocation, so recover or delete only this GUID path. Never glob for
# ~RF*.TMP: a concurrent selector may own those files.
$maxAttempts = 20
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
$backupItem = Get-Item -LiteralPath $BackupFile -Force -ErrorAction SilentlyContinue
if (-not $backupItem) { return $true }
# A failed inspection is transient under concurrent writers: retry.
if (-not (Test-SafeOwnedBackupFile $BackupFile)) { Start-Sleep -Milliseconds 25; continue }
$currentActive = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
# After our own successful replacement the backup holds a superseded
# value: only delete it, never move it back over a newer pointer.
if ($ReplaceSucceeded -or $currentActive) {
if (-not $ReplaceSucceeded -and -not (Test-SafeActiveFile)) { Start-Sleep -Milliseconds 25; continue }
try {
[IO.File]::Delete($BackupFile)
return $true
} catch {
if ($attempt -eq $maxAttempts) { return $false }
}
} else {
try {
[IO.File]::Move($BackupFile, $ActiveFile)
return $true
} catch {
# Another selector may have recreated the pointer between the
# absence check and Move. Reinspect before deciding whether the
# owned backup is now redundant.
if ($attempt -eq $maxAttempts) { return $false }
}
}
Start-Sleep -Milliseconds 25
}
return $false
}
function Get-CurrentActivePlan {
if (-not (Test-SafeActiveFile -AllowLink)) { return "" }
try {
@@ -305,9 +354,16 @@ if ($activeItem -and -not (Test-SafeActiveFile)) {
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
# Replace the directory entry instead of truncating an existing inode: a
# hardlinked pointer must not overwrite another file. The exclusive temporary
# file lives beside the pointer, keeping replacement on the same filesystem.
$tempFile = Join-Path $PlanRoot ('.active_plan.' + [guid]::NewGuid().ToString('N') + '.tmp')
# file and caller-owned backup live beside the pointer, keeping replacement on
# the same filesystem. Naming the backup prevents ReplaceFile from inventing
# an unowned ~RF*.TMP path if its final rename fails.
$operationId = [guid]::NewGuid().ToString('N')
$tempFile = Join-Path $PlanRoot ('.active_plan.' + $operationId + '.tmp')
$backupFile = Join-Path $PlanRoot ('.active_plan.' + $operationId + '.replace-backup')
$createdTemp = $false
$failureMessage = ""
$backupResolved = $true
$replaced = $false
try {
$stream = [IO.File]::Open($tempFile, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
$createdTemp = $true
@@ -316,19 +372,48 @@ try {
$stream.Write($bytes, 0, $bytes.Length)
$stream.Flush()
} finally { $stream.Dispose() }
if ($activeItem) {
[IO.File]::Replace($tempFile, $ActiveFile, [NullString]::Value)
} else {
[IO.File]::Move($tempFile, $ActiveFile)
$maxAttempts = 20
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
try {
$currentActive = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
if ($currentActive) {
if (-not (Test-SafeActiveFile)) {
throw [InvalidOperationException]::new("the active plan pointer became unsafe during replacement")
}
[IO.File]::Replace($tempFile, $ActiveFile, $backupFile)
} else {
[IO.File]::Move($tempFile, $ActiveFile)
}
$replaced = $true
break
} catch [IO.IOException] {
if (-not (Resolve-OwnedPointerBackup -BackupFile $backupFile)) {
throw [IO.IOException]::new("could not safely recover the active plan pointer after replacement failure")
}
if ($attempt -eq $maxAttempts) { throw }
Start-Sleep -Milliseconds 25
}
}
} catch {
Write-Error "Error: could not set the active plan pointer: $($_.Exception.Message)"
exit 1
$failureMessage = $_.Exception.Message
} finally {
if (-not (Resolve-OwnedPointerBackup -BackupFile $backupFile -ReplaceSucceeded:$replaced)) {
# The pointer was written; a leftover backup is clutter, not failure.
if ($replaced) { Write-Warning "could not remove the replacement backup $backupFile" }
else { $backupResolved = $false }
}
if ($createdTemp -and (Test-Path -LiteralPath $tempFile)) {
Remove-Item -LiteralPath $tempFile -Force -ErrorAction SilentlyContinue
}
}
if (-not $backupResolved) {
Write-Error "Error: could not safely recover the active plan pointer backup."
exit 1
}
if ($failureMessage) {
Write-Error "Error: could not set the active plan pointer: $failureMessage"
exit 1
}
Write-Output "Active plan set to: $PlanId"
Write-Output "Path: $PlanDir"
Write-Output ""
@@ -8,6 +8,12 @@ State what the analysis should determine or produce in one clear sentence.
[One sentence describing the analytical objective]
## Next Step
Record the single analytical action that should happen next. Update it whenever the active phase or immediate action changes.
[The single next analytical action. Update whenever phase status changes.]
## Current Phase
Name the phase currently being worked on.
@@ -84,6 +90,6 @@ Record each distinct error, the attempt number, and the resolution. Change the a
## Notes
- Update phase status as work progresses: `pending` to `in_progress` to `complete`.
- Re-read the goal and current phase before major analytical decisions.
- Re-read the goal, next step, and current phase before major analytical decisions.
- Log errors promptly so failed approaches are not repeated.
- Record query results and visual findings in findings.md while the evidence is available.
@@ -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.4"
version: "3.21.0"
---
@@ -5,6 +5,8 @@ param(
[string]$ProjectName = "project"
)
$ErrorActionPreference = "Stop"
$DATE = Get-Date -Format "yyyy-MM-dd"
Write-Host "Initializing planning files for: $ProjectName"
@@ -128,9 +128,58 @@ function Test-SafeActiveFile {
$linked = ([string]$item.LinkType) -in @('SymbolicLink', 'Junction')
return -not $item.PSIsContainer -and
($AllowLink -or -not $linked) -and
($AllowLink -or -not $item.IsReadOnly) -and
(Test-WithinRoot $ActiveFile)
}
function Test-SafeOwnedBackupFile {
param([string]$Path)
$item = Get-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue
if (-not $item) { return $false }
$linked = ([string]$item.LinkType) -in @('SymbolicLink', 'Junction')
return -not $item.PSIsContainer -and -not $linked -and (Test-WithinRoot $Path)
}
function Resolve-OwnedPointerBackup {
param([string]$BackupFile, [switch]$ReplaceSucceeded)
# ReplaceFile can move the old destination before failing to move the new
# file. A caller-named backup makes that intermediate state attributable to
# this invocation, so recover or delete only this GUID path. Never glob for
# ~RF*.TMP: a concurrent selector may own those files.
$maxAttempts = 20
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
$backupItem = Get-Item -LiteralPath $BackupFile -Force -ErrorAction SilentlyContinue
if (-not $backupItem) { return $true }
# A failed inspection is transient under concurrent writers: retry.
if (-not (Test-SafeOwnedBackupFile $BackupFile)) { Start-Sleep -Milliseconds 25; continue }
$currentActive = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
# After our own successful replacement the backup holds a superseded
# value: only delete it, never move it back over a newer pointer.
if ($ReplaceSucceeded -or $currentActive) {
if (-not $ReplaceSucceeded -and -not (Test-SafeActiveFile)) { Start-Sleep -Milliseconds 25; continue }
try {
[IO.File]::Delete($BackupFile)
return $true
} catch {
if ($attempt -eq $maxAttempts) { return $false }
}
} else {
try {
[IO.File]::Move($BackupFile, $ActiveFile)
return $true
} catch {
# Another selector may have recreated the pointer between the
# absence check and Move. Reinspect before deciding whether the
# owned backup is now redundant.
if ($attempt -eq $maxAttempts) { return $false }
}
}
Start-Sleep -Milliseconds 25
}
return $false
}
function Get-CurrentActivePlan {
if (-not (Test-SafeActiveFile -AllowLink)) { return "" }
try {
@@ -305,9 +354,16 @@ if ($activeItem -and -not (Test-SafeActiveFile)) {
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
# Replace the directory entry instead of truncating an existing inode: a
# hardlinked pointer must not overwrite another file. The exclusive temporary
# file lives beside the pointer, keeping replacement on the same filesystem.
$tempFile = Join-Path $PlanRoot ('.active_plan.' + [guid]::NewGuid().ToString('N') + '.tmp')
# file and caller-owned backup live beside the pointer, keeping replacement on
# the same filesystem. Naming the backup prevents ReplaceFile from inventing
# an unowned ~RF*.TMP path if its final rename fails.
$operationId = [guid]::NewGuid().ToString('N')
$tempFile = Join-Path $PlanRoot ('.active_plan.' + $operationId + '.tmp')
$backupFile = Join-Path $PlanRoot ('.active_plan.' + $operationId + '.replace-backup')
$createdTemp = $false
$failureMessage = ""
$backupResolved = $true
$replaced = $false
try {
$stream = [IO.File]::Open($tempFile, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
$createdTemp = $true
@@ -316,19 +372,48 @@ try {
$stream.Write($bytes, 0, $bytes.Length)
$stream.Flush()
} finally { $stream.Dispose() }
if ($activeItem) {
[IO.File]::Replace($tempFile, $ActiveFile, [NullString]::Value)
} else {
[IO.File]::Move($tempFile, $ActiveFile)
$maxAttempts = 20
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
try {
$currentActive = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
if ($currentActive) {
if (-not (Test-SafeActiveFile)) {
throw [InvalidOperationException]::new("the active plan pointer became unsafe during replacement")
}
[IO.File]::Replace($tempFile, $ActiveFile, $backupFile)
} else {
[IO.File]::Move($tempFile, $ActiveFile)
}
$replaced = $true
break
} catch [IO.IOException] {
if (-not (Resolve-OwnedPointerBackup -BackupFile $backupFile)) {
throw [IO.IOException]::new("could not safely recover the active plan pointer after replacement failure")
}
if ($attempt -eq $maxAttempts) { throw }
Start-Sleep -Milliseconds 25
}
}
} catch {
Write-Error "Error: could not set the active plan pointer: $($_.Exception.Message)"
exit 1
$failureMessage = $_.Exception.Message
} finally {
if (-not (Resolve-OwnedPointerBackup -BackupFile $backupFile -ReplaceSucceeded:$replaced)) {
# The pointer was written; a leftover backup is clutter, not failure.
if ($replaced) { Write-Warning "could not remove the replacement backup $backupFile" }
else { $backupResolved = $false }
}
if ($createdTemp -and (Test-Path -LiteralPath $tempFile)) {
Remove-Item -LiteralPath $tempFile -Force -ErrorAction SilentlyContinue
}
}
if (-not $backupResolved) {
Write-Error "Error: could not safely recover the active plan pointer backup."
exit 1
}
if ($failureMessage) {
Write-Error "Error: could not set the active plan pointer: $failureMessage"
exit 1
}
Write-Output "Active plan set to: $PlanId"
Write-Output "Path: $PlanDir"
Write-Output ""
@@ -372,6 +372,56 @@ describe("init and status", () => {
expect(buildContext(root, dir)).toContain("[planning-with-files] plan: ")
})
it("updates a regular active plan pointer through a directory junction project root", () => {
const linkedRoot = `${root}-junction`
fs.symlinkSync(root, linkedRoot, "junction")
try {
const first = initPlan(linkedRoot, { name: "First" }, env)
expect(first.ok).toBe(true)
const second = initPlan(linkedRoot, { name: "Second" }, env)
expect(second.ok).toBe(true)
const pointer = path.join(root, ".planning", ".active_plan")
expect(fs.lstatSync(pointer).isFile()).toBe(true)
expect(fs.readFileSync(pointer, "utf8")).toBe(`${second.plan_id}\n`)
} finally {
fs.rmSync(linkedRoot, { recursive: true, force: true })
}
})
it("refuses a linked planning directory without changing its external pointer", () => {
const outside = `${root}-outside`
const planning = path.join(root, ".planning")
fs.mkdirSync(outside)
const pointer = path.join(outside, ".active_plan")
fs.writeFileSync(pointer, "external-plan\n")
fs.symlinkSync(outside, planning, "junction")
try {
const result = initPlan(root, { name: "Blocked" }, env)
expect(result.ok).toBe(false)
expect(result.error).toContain("unsafe active plan pointer")
expect(fs.readFileSync(pointer, "utf8")).toBe("external-plan\n")
expect(fs.readdirSync(outside)).toEqual([".active_plan"])
} finally {
fs.rmSync(planning, { recursive: true, force: true })
fs.rmSync(outside, { recursive: true, force: true })
}
})
it("refuses a hard-linked active plan pointer without modifying its sibling", () => {
const planning = path.join(root, ".planning")
fs.mkdirSync(planning, { recursive: true })
const sibling = path.join(root, "pointer-sibling.txt")
fs.writeFileSync(sibling, "existing-plan\n")
fs.linkSync(sibling, path.join(planning, ".active_plan"))
const result = initPlan(root, { name: "Night Run" }, env)
expect(result.ok).toBe(false)
expect(result.error).toContain("unsafe active plan pointer")
expect(fs.readFileSync(sibling, "utf8")).toBe("existing-plan\n")
expect(fs.readdirSync(planning)).toEqual([".active_plan"])
})
it("uses a real skill template when one is discoverable, and rejects unknown modes", () => {
const skill = path.join(root, ".agents", "skills", "planning-with-files", "templates")
fs.mkdirSync(skill, { recursive: true })
@@ -86,6 +86,40 @@ function writeText(target: string, text: string): void {
fs.writeFileSync(target, text, { encoding: "utf8" })
}
function activePlanPointerError(planningRoot: string): string | null {
const pointer = path.join(planningRoot, ".active_plan")
const realRoot = realpathOrNull(planningRoot)
if (!isRealDir(planningRoot) || !realRoot) return `refusing to replace unsafe active plan pointer: ${pointer}`
if (!fs.existsSync(pointer)) return null
const st = lstatSafe(pointer)
const real = realpathOrNull(pointer)
return !st || !st.isFile() || st.isSymbolicLink() || st.nlink > 1 || !real || !isInside(real, realRoot)
? `refusing to replace unsafe active plan pointer: ${pointer}`
: null
}
function writeActivePlan(planningRoot: string, planId: string): string | null {
const pointer = path.join(planningRoot, ".active_plan")
const unsafe = activePlanPointerError(planningRoot)
if (unsafe) return unsafe
const tmp = path.join(planningRoot, `.active_plan.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`)
let fd: number | null = null
try {
fd = fs.openSync(tmp, "wx")
fs.writeFileSync(fd, `${planId}\n`, { encoding: "utf8" })
fs.closeSync(fd)
fd = null
fs.renameSync(tmp, pointer)
return null
} catch (err) {
return `could not safely update active plan pointer: ${err instanceof Error ? err.message : String(err)}`
} finally {
if (fd !== null) fs.closeSync(fd)
try { fs.unlinkSync(tmp) } catch { /* already renamed or absent */ }
}
}
function realpathOrNull(target: string): string | null {
try {
return fs.realpathSync(target)
@@ -736,8 +770,11 @@ export function initPlan(
fs.mkdirSync(planningRoot, { recursive: true })
planId = `${new Date().toISOString().slice(0, 10)}-${slug}`
planDir = path.join(planningRoot, planId)
const pointerError = activePlanPointerError(planningRoot)
if (pointerError) return { ok: false, error: pointerError, project_dir: root }
fs.mkdirSync(planDir, { recursive: true })
writeText(path.join(planningRoot, ".active_plan"), `${planId}\n`)
const writeError = writeActivePlan(planningRoot, planId)
if (writeError) return { ok: false, error: writeError, project_dir: root }
}
const created = copyTemplates(planDir, templatesDir, template)
const result: InitResult = {
@@ -36,7 +36,7 @@ hooks:
- type: command
command: "SH=\"\"; for c in \"${PWF_SCRIPT_DIR}/skill-hook.sh\" \"${CLAUDE_SKILL_DIR}/scripts/skill-hook.sh\" \"$HOME/.config/opencode/skills/planning-with-files/scripts/skill-hook.sh\" \"$HOME/.opencode/skills/planning-with-files/scripts/skill-hook.sh\" \"$HOME/.claude/skills/planning-with-files/scripts/skill-hook.sh\" \"$HOME/.claude/plugins/marketplaces/planning-with-files/scripts/skill-hook.sh\"; do [ -f \"$c\" ] && { SH=\"$c\"; break; }; done; [ -n \"$SH\" ] && sh \"$SH\" --event=precompact; exit 0"
metadata:
version: "3.20.4"
version: "3.21.0"
---
@@ -5,6 +5,8 @@ param(
[string]$ProjectName = "project"
)
$ErrorActionPreference = "Stop"
$DATE = Get-Date -Format "yyyy-MM-dd"
Write-Host "Initializing planning files for: $ProjectName"
@@ -128,9 +128,58 @@ function Test-SafeActiveFile {
$linked = ([string]$item.LinkType) -in @('SymbolicLink', 'Junction')
return -not $item.PSIsContainer -and
($AllowLink -or -not $linked) -and
($AllowLink -or -not $item.IsReadOnly) -and
(Test-WithinRoot $ActiveFile)
}
function Test-SafeOwnedBackupFile {
param([string]$Path)
$item = Get-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue
if (-not $item) { return $false }
$linked = ([string]$item.LinkType) -in @('SymbolicLink', 'Junction')
return -not $item.PSIsContainer -and -not $linked -and (Test-WithinRoot $Path)
}
function Resolve-OwnedPointerBackup {
param([string]$BackupFile, [switch]$ReplaceSucceeded)
# ReplaceFile can move the old destination before failing to move the new
# file. A caller-named backup makes that intermediate state attributable to
# this invocation, so recover or delete only this GUID path. Never glob for
# ~RF*.TMP: a concurrent selector may own those files.
$maxAttempts = 20
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
$backupItem = Get-Item -LiteralPath $BackupFile -Force -ErrorAction SilentlyContinue
if (-not $backupItem) { return $true }
# A failed inspection is transient under concurrent writers: retry.
if (-not (Test-SafeOwnedBackupFile $BackupFile)) { Start-Sleep -Milliseconds 25; continue }
$currentActive = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
# After our own successful replacement the backup holds a superseded
# value: only delete it, never move it back over a newer pointer.
if ($ReplaceSucceeded -or $currentActive) {
if (-not $ReplaceSucceeded -and -not (Test-SafeActiveFile)) { Start-Sleep -Milliseconds 25; continue }
try {
[IO.File]::Delete($BackupFile)
return $true
} catch {
if ($attempt -eq $maxAttempts) { return $false }
}
} else {
try {
[IO.File]::Move($BackupFile, $ActiveFile)
return $true
} catch {
# Another selector may have recreated the pointer between the
# absence check and Move. Reinspect before deciding whether the
# owned backup is now redundant.
if ($attempt -eq $maxAttempts) { return $false }
}
}
Start-Sleep -Milliseconds 25
}
return $false
}
function Get-CurrentActivePlan {
if (-not (Test-SafeActiveFile -AllowLink)) { return "" }
try {
@@ -305,9 +354,16 @@ if ($activeItem -and -not (Test-SafeActiveFile)) {
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
# Replace the directory entry instead of truncating an existing inode: a
# hardlinked pointer must not overwrite another file. The exclusive temporary
# file lives beside the pointer, keeping replacement on the same filesystem.
$tempFile = Join-Path $PlanRoot ('.active_plan.' + [guid]::NewGuid().ToString('N') + '.tmp')
# file and caller-owned backup live beside the pointer, keeping replacement on
# the same filesystem. Naming the backup prevents ReplaceFile from inventing
# an unowned ~RF*.TMP path if its final rename fails.
$operationId = [guid]::NewGuid().ToString('N')
$tempFile = Join-Path $PlanRoot ('.active_plan.' + $operationId + '.tmp')
$backupFile = Join-Path $PlanRoot ('.active_plan.' + $operationId + '.replace-backup')
$createdTemp = $false
$failureMessage = ""
$backupResolved = $true
$replaced = $false
try {
$stream = [IO.File]::Open($tempFile, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
$createdTemp = $true
@@ -316,19 +372,48 @@ try {
$stream.Write($bytes, 0, $bytes.Length)
$stream.Flush()
} finally { $stream.Dispose() }
if ($activeItem) {
[IO.File]::Replace($tempFile, $ActiveFile, [NullString]::Value)
} else {
[IO.File]::Move($tempFile, $ActiveFile)
$maxAttempts = 20
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
try {
$currentActive = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
if ($currentActive) {
if (-not (Test-SafeActiveFile)) {
throw [InvalidOperationException]::new("the active plan pointer became unsafe during replacement")
}
[IO.File]::Replace($tempFile, $ActiveFile, $backupFile)
} else {
[IO.File]::Move($tempFile, $ActiveFile)
}
$replaced = $true
break
} catch [IO.IOException] {
if (-not (Resolve-OwnedPointerBackup -BackupFile $backupFile)) {
throw [IO.IOException]::new("could not safely recover the active plan pointer after replacement failure")
}
if ($attempt -eq $maxAttempts) { throw }
Start-Sleep -Milliseconds 25
}
}
} catch {
Write-Error "Error: could not set the active plan pointer: $($_.Exception.Message)"
exit 1
$failureMessage = $_.Exception.Message
} finally {
if (-not (Resolve-OwnedPointerBackup -BackupFile $backupFile -ReplaceSucceeded:$replaced)) {
# The pointer was written; a leftover backup is clutter, not failure.
if ($replaced) { Write-Warning "could not remove the replacement backup $backupFile" }
else { $backupResolved = $false }
}
if ($createdTemp -and (Test-Path -LiteralPath $tempFile)) {
Remove-Item -LiteralPath $tempFile -Force -ErrorAction SilentlyContinue
}
}
if (-not $backupResolved) {
Write-Error "Error: could not safely recover the active plan pointer backup."
exit 1
}
if ($failureMessage) {
Write-Error "Error: could not set the active plan pointer: $failureMessage"
exit 1
}
Write-Output "Active plan set to: $PlanId"
Write-Output "Path: $PlanDir"
Write-Output ""
@@ -8,6 +8,12 @@ State the analytical question or intended deliverable in one clear sentence.
[One sentence describing the analytical objective]
## Next Step
Record the single analytical action that should happen next. Update it whenever the active phase or immediate action changes.
[The single next analytical action. Update whenever phase status changes.]
## Current Phase
Name the phase currently being worked on.
@@ -76,6 +82,6 @@ Record each distinct error, the attempt number, and the resolution. Change the a
## Notes
- Update phase status as work progresses: `pending` to `in_progress` to `complete`.
- Re-read the goal and current phase before major analytical decisions.
- Re-read the goal, next step, and current phase before major analytical decisions.
- Log errors promptly so failed approaches are not repeated.
- Record query results and visual evidence in findings.md.

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