planning-with-files 3.11.2 → 3.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -11
- package/SKILL.md +497 -486
- package/extensions/planning-with-files/package.json +1 -1
- package/extensions/planning-with-files/runtime.ts +7 -7
- package/package.json +56 -53
- package/scripts/attest-plan.ps1 +405 -34
- package/scripts/attest-plan.sh +37 -1
- package/scripts/init-session.sh +19 -0
- package/scripts/inject-plan.sh +727 -117
- package/scripts/phase-status.ps1 +79 -17
- package/scripts/phase-status.sh +60 -16
- package/scripts/resolve-plan-dir.ps1 +77 -7
- package/scripts/resolve-plan-dir.sh +61 -21
- package/scripts/session-catchup.py +131 -24
- package/scripts/verify-shell-line-endings.mjs +50 -0
- package/templates/analytics_findings.md +67 -85
- package/templates/analytics_task_plan.md +81 -106
- package/templates/findings.md +47 -95
- package/templates/progress.md +58 -114
- package/templates/task_plan.md +89 -140
package/scripts/phase-status.ps1
CHANGED
|
@@ -6,9 +6,10 @@
|
|
|
6
6
|
.DESCRIPTION
|
|
7
7
|
The ONLY sanctioned concurrent-safe writer of task_plan.md status lines. The
|
|
8
8
|
orchestrator owns task_plan.md; workers NEVER edit it directly. The edit is
|
|
9
|
-
a read-modify-write under
|
|
10
|
-
|
|
11
|
-
leave a half-rewritten plan
|
|
9
|
+
a read-modify-write under the portable
|
|
10
|
+
<plan-dir>\.pwf-locks\phase-status.lock directory lock, with an atomic
|
|
11
|
+
temp-file + move swap so a torn write can never leave a half-rewritten plan
|
|
12
|
+
on disk (architecture C4).
|
|
12
13
|
|
|
13
14
|
Editing task_plan.md changes its SHA, so the orchestrator must re-attest at
|
|
14
15
|
phase boundaries (see attest-plan.ps1).
|
|
@@ -76,6 +77,70 @@ function Resolve-PlanFile {
|
|
|
76
77
|
return $null
|
|
77
78
|
}
|
|
78
79
|
|
|
80
|
+
function Enter-PwfDirectoryLock {
|
|
81
|
+
param(
|
|
82
|
+
[string] $LockRoot,
|
|
83
|
+
[string] $LockDir
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
[void][System.IO.Directory]::CreateDirectory($LockRoot)
|
|
88
|
+
} catch {
|
|
89
|
+
Write-Error ("[phase-status] Cannot create lock root " + $LockRoot + ": " + $_.Exception.Message)
|
|
90
|
+
return $null
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
$token = "phase-status-" + $PID + "-" + [Guid]::NewGuid().ToString("N")
|
|
94
|
+
$ownerFile = Join-Path $LockDir ".owner"
|
|
95
|
+
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
|
96
|
+
|
|
97
|
+
$wait = [Diagnostics.Stopwatch]::StartNew()
|
|
98
|
+
while ($wait.Elapsed.TotalSeconds -lt 5) {
|
|
99
|
+
$createdByUs = $false
|
|
100
|
+
try {
|
|
101
|
+
New-Item -Path $LockDir -ItemType Directory -ErrorAction Stop | Out-Null
|
|
102
|
+
$createdByUs = $true
|
|
103
|
+
[System.IO.File]::WriteAllText($ownerFile, $token + "`n", $utf8NoBom)
|
|
104
|
+
return [PSCustomObject]@{
|
|
105
|
+
Directory = $LockDir
|
|
106
|
+
OwnerFile = $ownerFile
|
|
107
|
+
Token = $token
|
|
108
|
+
}
|
|
109
|
+
} catch {
|
|
110
|
+
if ($createdByUs) {
|
|
111
|
+
try {
|
|
112
|
+
if ([System.IO.File]::Exists($ownerFile)) {
|
|
113
|
+
$ownerValue = [System.IO.File]::ReadAllText($ownerFile).Trim()
|
|
114
|
+
if ([string]::Equals($ownerValue, $token, [StringComparison]::Ordinal)) {
|
|
115
|
+
[System.IO.File]::Delete($ownerFile)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
[System.IO.Directory]::Delete($LockDir, $false)
|
|
119
|
+
} catch {
|
|
120
|
+
# Leave any directory we cannot prove is still ours intact.
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
Start-Sleep -Milliseconds 100
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return $null
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function Exit-PwfDirectoryLock {
|
|
131
|
+
param($Lock)
|
|
132
|
+
if (-not $Lock) { return }
|
|
133
|
+
try {
|
|
134
|
+
if (-not [System.IO.File]::Exists($Lock.OwnerFile)) { return }
|
|
135
|
+
$ownerValue = [System.IO.File]::ReadAllText($Lock.OwnerFile).Trim()
|
|
136
|
+
if (-not [string]::Equals($ownerValue, $Lock.Token, [StringComparison]::Ordinal)) { return }
|
|
137
|
+
[System.IO.File]::Delete($Lock.OwnerFile)
|
|
138
|
+
[System.IO.Directory]::Delete($Lock.Directory, $false)
|
|
139
|
+
} catch {
|
|
140
|
+
# Cleanup is best-effort and never removes a lock with another owner.
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
79
144
|
# Validate phase number is a positive integer.
|
|
80
145
|
if ($Phase -notmatch '^[0-9]+$') {
|
|
81
146
|
Write-Error ("[phase-status] phase number must be a positive integer, got '" + $Phase + "'.")
|
|
@@ -96,18 +161,16 @@ if (-not $planFile) {
|
|
|
96
161
|
}
|
|
97
162
|
|
|
98
163
|
$planDir = Split-Path -Parent $planFile
|
|
99
|
-
$
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
Start-Sleep -Milliseconds 100
|
|
110
|
-
}
|
|
164
|
+
$lockRoot = Join-Path $planDir ".pwf-locks"
|
|
165
|
+
$lockDir = Join-Path $lockRoot "phase-status.lock"
|
|
166
|
+
|
|
167
|
+
# Atomic directory creation is the common lock primitive used by both the sh
|
|
168
|
+
# and PowerShell implementations. Failure to acquire within about five seconds
|
|
169
|
+
# is fail-closed: no plan read/rewrite is attempted.
|
|
170
|
+
$lock = Enter-PwfDirectoryLock -LockRoot $lockRoot -LockDir $lockDir
|
|
171
|
+
if (-not $lock) {
|
|
172
|
+
Write-Error ("[phase-status] Timed out waiting for lock " + $lockDir + ". No plan changes were made.")
|
|
173
|
+
exit 75
|
|
111
174
|
}
|
|
112
175
|
|
|
113
176
|
$tmpFile = $planFile + ".tmp." + $PID
|
|
@@ -164,8 +227,7 @@ try {
|
|
|
164
227
|
Write-Error ("[phase-status] " + $_.Exception.Message)
|
|
165
228
|
$rc = 1
|
|
166
229
|
} finally {
|
|
167
|
-
|
|
168
|
-
if (Test-Path -LiteralPath $lockFile) { Remove-Item -LiteralPath $lockFile -Force -ErrorAction SilentlyContinue }
|
|
230
|
+
Exit-PwfDirectoryLock -Lock $lock
|
|
169
231
|
if (Test-Path -LiteralPath $tmpFile) { Remove-Item -LiteralPath $tmpFile -Force -ErrorAction SilentlyContinue }
|
|
170
232
|
}
|
|
171
233
|
|
package/scripts/phase-status.sh
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
#
|
|
4
4
|
# This is the ONLY sanctioned concurrent-safe writer of task_plan.md status
|
|
5
5
|
# lines. The orchestrator owns task_plan.md; workers NEVER edit it directly.
|
|
6
|
-
# All status edits go through this read-modify-write under
|
|
7
|
-
#
|
|
8
|
-
# torn write can never leave a half-rewritten plan on
|
|
6
|
+
# All status edits go through this read-modify-write under the portable
|
|
7
|
+
# <plan-dir>/.pwf-locks/phase-status.lock directory lock, with an atomic
|
|
8
|
+
# temp-file + mv swap so a torn write can never leave a half-rewritten plan on
|
|
9
|
+
# disk (architecture C4).
|
|
9
10
|
#
|
|
10
11
|
# Note: editing task_plan.md changes its SHA, so the orchestrator must
|
|
11
12
|
# re-attest at phase boundaries (see attest-plan.sh).
|
|
@@ -77,9 +78,62 @@ PLAN_FILE="$(resolve_plan_file)" || {
|
|
|
77
78
|
}
|
|
78
79
|
|
|
79
80
|
PLAN_DIR="$(dirname "${PLAN_FILE}")"
|
|
80
|
-
|
|
81
|
+
LOCK_ROOT="${PLAN_DIR}/.pwf-locks"
|
|
82
|
+
LOCK_DIR="${LOCK_ROOT}/phase-status.lock"
|
|
83
|
+
LOCK_TOKEN=""
|
|
84
|
+
LOCK_ACQUIRED=0
|
|
81
85
|
|
|
82
|
-
|
|
86
|
+
release_lock() {
|
|
87
|
+
if [ "${LOCK_ACQUIRED}" -ne 1 ] || [ -z "${LOCK_TOKEN}" ]; then
|
|
88
|
+
return 0
|
|
89
|
+
fi
|
|
90
|
+
owner_file="${LOCK_DIR}/.owner"
|
|
91
|
+
owner_value="$(cat "${owner_file}" 2>/dev/null || true)"
|
|
92
|
+
if [ "${owner_value}" = "${LOCK_TOKEN}" ]; then
|
|
93
|
+
rm -f "${owner_file}" 2>/dev/null || true
|
|
94
|
+
rmdir "${LOCK_DIR}" 2>/dev/null || true
|
|
95
|
+
fi
|
|
96
|
+
LOCK_ACQUIRED=0
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
acquire_lock() {
|
|
100
|
+
mkdir -p "${LOCK_ROOT}" 2>/dev/null || {
|
|
101
|
+
printf "[phase-status] Cannot create lock root %s.\n" "${LOCK_ROOT}" >&2
|
|
102
|
+
return 1
|
|
103
|
+
}
|
|
104
|
+
LOCK_TOKEN="phase-status-$$-$(date +%s 2>/dev/null || printf 0)"
|
|
105
|
+
started_at="$(date +%s 2>/dev/null || printf 0)"
|
|
106
|
+
attempts=0
|
|
107
|
+
while ! mkdir "${LOCK_DIR}" 2>/dev/null; do
|
|
108
|
+
attempts=$((attempts + 1))
|
|
109
|
+
now="$(date +%s 2>/dev/null || printf 0)"
|
|
110
|
+
if { [ "${started_at}" -gt 0 ] 2>/dev/null \
|
|
111
|
+
&& [ $((now - started_at)) -ge 5 ]; } \
|
|
112
|
+
|| [ "${attempts}" -ge 50 ]; then
|
|
113
|
+
printf "[phase-status] Timed out waiting for lock %s. No plan changes were made.\n" "${LOCK_DIR}" >&2
|
|
114
|
+
return 75
|
|
115
|
+
fi
|
|
116
|
+
sleep 0.1
|
|
117
|
+
done
|
|
118
|
+
if ! printf '%s\n' "${LOCK_TOKEN}" > "${LOCK_DIR}/.owner" 2>/dev/null; then
|
|
119
|
+
rmdir "${LOCK_DIR}" 2>/dev/null || true
|
|
120
|
+
printf "[phase-status] Cannot record lock ownership in %s.\n" "${LOCK_DIR}" >&2
|
|
121
|
+
return 1
|
|
122
|
+
fi
|
|
123
|
+
LOCK_ACQUIRED=1
|
|
124
|
+
return 0
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
trap 'release_lock' EXIT
|
|
128
|
+
trap 'release_lock; exit 1' HUP INT TERM
|
|
129
|
+
|
|
130
|
+
acquire_lock
|
|
131
|
+
lock_rc=$?
|
|
132
|
+
if [ "${lock_rc}" -ne 0 ]; then
|
|
133
|
+
exit "${lock_rc}"
|
|
134
|
+
fi
|
|
135
|
+
|
|
136
|
+
# Confirm the phase heading exists while holding the same lock as the rewrite.
|
|
83
137
|
if ! grep -q "### Phase ${PHASE_NUM}\b" "${PLAN_FILE}" 2>/dev/null; then
|
|
84
138
|
# Fall back to a looser match for headings like "### Phase 1:" where \b may
|
|
85
139
|
# not be honored by a minimal grep.
|
|
@@ -137,17 +191,7 @@ do_write() {
|
|
|
137
191
|
}
|
|
138
192
|
|
|
139
193
|
rc=0
|
|
140
|
-
|
|
141
|
-
(
|
|
142
|
-
flock -w 5 9 || true
|
|
143
|
-
do_write
|
|
144
|
-
) 9>"${LOCK_FILE}" 2>/dev/null
|
|
145
|
-
rc=$?
|
|
146
|
-
rm -f "${LOCK_FILE}" 2>/dev/null || true
|
|
147
|
-
else
|
|
148
|
-
do_write
|
|
149
|
-
rc=$?
|
|
150
|
-
fi
|
|
194
|
+
do_write || rc=$?
|
|
151
195
|
|
|
152
196
|
if [ "${rc}" -ne 0 ]; then
|
|
153
197
|
rm -f "${TMP_FILE}" 2>/dev/null
|
|
@@ -18,6 +18,64 @@ param(
|
|
|
18
18
|
|
|
19
19
|
$projectRoot = (Get-Location).Path
|
|
20
20
|
|
|
21
|
+
# Resolve-Path is lexical for Windows junctions: it can return the junction's
|
|
22
|
+
# spelling rather than the directory opened by the filesystem. Use a directory
|
|
23
|
+
# handle and GetFinalPathNameByHandleW on Windows so containment is decided from
|
|
24
|
+
# the object the kernel actually opened.
|
|
25
|
+
$script:IsWindowsHost = [Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT
|
|
26
|
+
if ($script:IsWindowsHost -and -not ("PwfResolverNative" -as [type])) {
|
|
27
|
+
Add-Type -TypeDefinition @'
|
|
28
|
+
using System;
|
|
29
|
+
using System.ComponentModel;
|
|
30
|
+
using System.Runtime.InteropServices;
|
|
31
|
+
using System.Text;
|
|
32
|
+
using Microsoft.Win32.SafeHandles;
|
|
33
|
+
|
|
34
|
+
public static class PwfResolverNative {
|
|
35
|
+
private const uint FILE_SHARE_READ = 0x00000001;
|
|
36
|
+
private const uint FILE_SHARE_WRITE = 0x00000002;
|
|
37
|
+
private const uint FILE_SHARE_DELETE = 0x00000004;
|
|
38
|
+
private const uint OPEN_EXISTING = 3;
|
|
39
|
+
private const uint FILE_FLAG_BACKUP_SEMANTICS = 0x02000000;
|
|
40
|
+
|
|
41
|
+
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
|
42
|
+
private static extern SafeFileHandle CreateFileW(
|
|
43
|
+
string name, uint access, uint share, IntPtr security,
|
|
44
|
+
uint creation, uint flags, IntPtr template);
|
|
45
|
+
|
|
46
|
+
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
|
47
|
+
private static extern uint GetFinalPathNameByHandleW(
|
|
48
|
+
SafeFileHandle handle, StringBuilder path, uint length, uint flags);
|
|
49
|
+
|
|
50
|
+
public static string FinalDirectoryPath(string path) {
|
|
51
|
+
using (SafeFileHandle handle = CreateFileW(
|
|
52
|
+
path, 0, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
|
|
53
|
+
IntPtr.Zero, OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS, IntPtr.Zero)) {
|
|
54
|
+
if (handle.IsInvalid) throw new Win32Exception(Marshal.GetLastWin32Error());
|
|
55
|
+
StringBuilder buffer = new StringBuilder(32768);
|
|
56
|
+
uint length = GetFinalPathNameByHandleW(handle, buffer, (uint)buffer.Capacity, 0);
|
|
57
|
+
if (length == 0 || length >= buffer.Capacity)
|
|
58
|
+
throw new Win32Exception(Marshal.GetLastWin32Error());
|
|
59
|
+
string result = buffer.ToString();
|
|
60
|
+
if (result.StartsWith(@"\\?\UNC\", StringComparison.OrdinalIgnoreCase))
|
|
61
|
+
return @"\\" + result.Substring(8);
|
|
62
|
+
if (result.StartsWith(@"\\?\", StringComparison.OrdinalIgnoreCase))
|
|
63
|
+
return result.Substring(4);
|
|
64
|
+
return result;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
'@
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function Get-FinalDirectoryPath {
|
|
72
|
+
param([string]$Path)
|
|
73
|
+
if ($script:IsWindowsHost) {
|
|
74
|
+
return [PwfResolverNative]::FinalDirectoryPath($Path)
|
|
75
|
+
}
|
|
76
|
+
return (Resolve-Path -LiteralPath $Path -ErrorAction Stop).Path
|
|
77
|
+
}
|
|
78
|
+
|
|
21
79
|
# PWF_PLAN_ROOT: absolute plan-root binding (issue #212), mirroring
|
|
22
80
|
# resolve-plan-dir.sh. A thread whose cwd is a shared PARENT of the real
|
|
23
81
|
# project resolves the parent's plan and never sees the nested one;
|
|
@@ -30,9 +88,12 @@ $projectRoot = (Get-Location).Path
|
|
|
30
88
|
# user-facing notice; stdout here is the data channel). Containment is then
|
|
31
89
|
# checked against the pinned root. Unset keeps legacy behavior unchanged.
|
|
32
90
|
if ($env:PWF_PLAN_ROOT) {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
91
|
+
$pin = $env:PWF_PLAN_ROOT
|
|
92
|
+
$isUnc = $pin.StartsWith('\\') -or $pin.StartsWith('//')
|
|
93
|
+
$isAbsolute = [System.IO.Path]::IsPathFullyQualified($pin)
|
|
94
|
+
if ($isAbsolute -and -not $isUnc -and (Test-Path -LiteralPath $pin -PathType Container)) {
|
|
95
|
+
$projectRoot = $pin
|
|
96
|
+
$PlanRoot = Join-Path $pin ".planning"
|
|
36
97
|
} else {
|
|
37
98
|
exit 0
|
|
38
99
|
}
|
|
@@ -55,8 +116,8 @@ function Test-ValidSlug {
|
|
|
55
116
|
function Test-WithinRoot {
|
|
56
117
|
param([string]$Candidate)
|
|
57
118
|
try {
|
|
58
|
-
$rootReal =
|
|
59
|
-
$candReal =
|
|
119
|
+
$rootReal = Get-FinalDirectoryPath $projectRoot
|
|
120
|
+
$candReal = Get-FinalDirectoryPath $Candidate
|
|
60
121
|
} catch {
|
|
61
122
|
return $false
|
|
62
123
|
}
|
|
@@ -79,8 +140,17 @@ if ($env:PLAN_ID) {
|
|
|
79
140
|
}
|
|
80
141
|
}
|
|
81
142
|
|
|
82
|
-
|
|
83
|
-
|
|
143
|
+
# Get-Item observes the link object even when its target is missing, unlike
|
|
144
|
+
# Test-Path which follows the target. An active pointer that is a directory or
|
|
145
|
+
# reparse point is an unsafe/ambiguous selector and must terminate resolution;
|
|
146
|
+
# falling through would silently select and expose the newest unrelated plan.
|
|
147
|
+
$activeItem = Get-Item -LiteralPath $activeFile -Force -ErrorAction SilentlyContinue
|
|
148
|
+
if ($activeItem) {
|
|
149
|
+
if ($activeItem.PSIsContainer -or
|
|
150
|
+
(($activeItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)) {
|
|
151
|
+
exit 0
|
|
152
|
+
}
|
|
153
|
+
$planId = (Get-Content -LiteralPath $activeFile -Raw).Trim()
|
|
84
154
|
if ($planId -and (Test-ValidSlug $planId)) {
|
|
85
155
|
$candidate = Join-Path $PlanRoot $planId
|
|
86
156
|
if ((Test-Path $candidate -PathType Container) -and (Test-WithinRoot $candidate)) {
|
|
@@ -31,7 +31,12 @@ PLAN_ROOT="${1:-${PWD}/.planning}"
|
|
|
31
31
|
# With the variable unset, behavior is byte-identical to the legacy shape.
|
|
32
32
|
PWF_ROOT_PIN=""
|
|
33
33
|
if [ -n "${PWF_PLAN_ROOT:-}" ]; then
|
|
34
|
-
|
|
34
|
+
case "${PWF_PLAN_ROOT}" in
|
|
35
|
+
\\\\*|//*|[A-Za-z]:[!\\/]*) _pwf_pin_absolute=0 ;;
|
|
36
|
+
/*|[A-Za-z]:[\\/]*) _pwf_pin_absolute=1 ;;
|
|
37
|
+
*) _pwf_pin_absolute=0 ;;
|
|
38
|
+
esac
|
|
39
|
+
if [ "$_pwf_pin_absolute" = "1" ] && [ -d "${PWF_PLAN_ROOT}" ]; then
|
|
35
40
|
PWF_ROOT_PIN="${PWF_PLAN_ROOT}"
|
|
36
41
|
PLAN_ROOT="${PWF_PLAN_ROOT}/.planning"
|
|
37
42
|
else
|
|
@@ -85,10 +90,54 @@ norm_slashes() {
|
|
|
85
90
|
done
|
|
86
91
|
}
|
|
87
92
|
|
|
93
|
+
# Return true when a candidate path names the Microsoft Store WindowsApps
|
|
94
|
+
# directory. Store app aliases are not stable interpreter binaries and may
|
|
95
|
+
# present as executable while refusing script execution. Matching is
|
|
96
|
+
# case-insensitive and works before or after Windows slash normalization.
|
|
97
|
+
is_windowsapps_path() {
|
|
98
|
+
norm_slashes "$1"
|
|
99
|
+
case "${NORM_OUT}" in
|
|
100
|
+
[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]|\
|
|
101
|
+
[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]/*|\
|
|
102
|
+
*/[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]|\
|
|
103
|
+
*/[Ww][Ii][Nn][Dd][Oo][Ww][Ss][Aa][Pp][Pp][Ss]/*) return 0 ;;
|
|
104
|
+
esac
|
|
105
|
+
return 1
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
# Select only an interpreter path the caller explicitly trusted.
|
|
109
|
+
# PWF_TRUSTED_PYTHON is preferred; PYTHON_BIN remains a compatibility alias.
|
|
110
|
+
# PATH discovery is intentionally forbidden because resolver hooks can run in
|
|
111
|
+
# repositories that control PATH. Windows-native absolute paths are converted
|
|
112
|
+
# with Git Bash's fixed system cygpath, never a PATH-selected shim.
|
|
113
|
+
trusted_python() {
|
|
114
|
+
for _tp_candidate in "${PWF_TRUSTED_PYTHON:-}" "${PYTHON_BIN:-}"; do
|
|
115
|
+
[ -n "${_tp_candidate}" ] || continue
|
|
116
|
+
case "${_tp_candidate}" in
|
|
117
|
+
\\\\*|//*) continue ;;
|
|
118
|
+
[A-Za-z]:[\\/]*)
|
|
119
|
+
is_windowsapps_path "${_tp_candidate}" && continue
|
|
120
|
+
_tp_cygpath="/usr/bin/cygpath.exe"
|
|
121
|
+
[ -f "${_tp_cygpath}" ] && [ -x "${_tp_cygpath}" ] || continue
|
|
122
|
+
_tp_candidate="$("${_tp_cygpath}" -u "${_tp_candidate}" 2>/dev/null)" \
|
|
123
|
+
|| continue
|
|
124
|
+
;;
|
|
125
|
+
/*) ;;
|
|
126
|
+
*) continue ;;
|
|
127
|
+
esac
|
|
128
|
+
is_windowsapps_path "${_tp_candidate}" && continue
|
|
129
|
+
[ -f "${_tp_candidate}" ] || continue
|
|
130
|
+
[ -x "${_tp_candidate}" ] || continue
|
|
131
|
+
printf "%s\n" "${_tp_candidate}"
|
|
132
|
+
return 0
|
|
133
|
+
done
|
|
134
|
+
return 1
|
|
135
|
+
}
|
|
136
|
+
|
|
88
137
|
# Portable path canonicalizer. realpath first (Linux, modern coreutils),
|
|
89
|
-
# then readlink -f (older GNU), then
|
|
90
|
-
# the canonical absolute path on success; prints nothing and returns 1
|
|
91
|
-
# full miss so
|
|
138
|
+
# then readlink -f (older GNU), then an explicitly trusted Python interpreter.
|
|
139
|
+
# Prints the canonical absolute path on success; prints nothing and returns 1
|
|
140
|
+
# on a full miss so containment fails closed. No Python spawn on the happy
|
|
92
141
|
# path: realpath/readlink cover Linux, WSL, Git-Bash, and modern macOS.
|
|
93
142
|
canonicalize() {
|
|
94
143
|
target="$1"
|
|
@@ -100,12 +149,9 @@ canonicalize() {
|
|
|
100
149
|
out="$(readlink -f "${target}" 2>/dev/null)" && [ -n "${out}" ] && {
|
|
101
150
|
printf "%s\n" "${out}"; return 0; }
|
|
102
151
|
fi
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
fi
|
|
107
|
-
if command -v python >/dev/null 2>&1; then
|
|
108
|
-
out="$(python -c "import os,sys;print(os.path.realpath(sys.argv[1]))" "${target}" 2>/dev/null)" \
|
|
152
|
+
_canonical_python="$(trusted_python)" || _canonical_python=""
|
|
153
|
+
if [ -n "${_canonical_python}" ]; then
|
|
154
|
+
out="$("${_canonical_python}" -c "import os,sys;print(os.path.realpath(sys.argv[1]))" "${target}" 2>/dev/null)" \
|
|
109
155
|
&& [ -n "${out}" ] && { printf "%s\n" "${out}"; return 0; }
|
|
110
156
|
fi
|
|
111
157
|
return 1
|
|
@@ -175,7 +221,8 @@ is_within_root() {
|
|
|
175
221
|
}
|
|
176
222
|
|
|
177
223
|
# Portable mtime resolver. Tries GNU stat, BSD stat, BSD/macOS date -r,
|
|
178
|
-
#
|
|
224
|
+
# then an explicitly trusted Python interpreter. Returns "0" on a full miss
|
|
225
|
+
# so newest-plan selection fails closed instead of executing from PATH.
|
|
179
226
|
mtime_of() {
|
|
180
227
|
target="$1"
|
|
181
228
|
out="$(stat -c '%Y' "${target}" 2>/dev/null)"
|
|
@@ -184,16 +231,9 @@ mtime_of() {
|
|
|
184
231
|
if [ -n "${out}" ]; then printf "%s\n" "${out}"; return 0; fi
|
|
185
232
|
out="$(date -r "${target}" +%s 2>/dev/null)"
|
|
186
233
|
if [ -n "${out}" ]; then printf "%s\n" "${out}"; return 0; fi
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
fi
|
|
191
|
-
if command -v python >/dev/null 2>&1; then
|
|
192
|
-
out="$(python -c "import os,sys;print(int(os.stat(sys.argv[1]).st_mtime))" "${target}" 2>/dev/null)"
|
|
193
|
-
if [ -n "${out}" ]; then printf "%s\n" "${out}"; return 0; fi
|
|
194
|
-
fi
|
|
195
|
-
if command -v perl >/dev/null 2>&1; then
|
|
196
|
-
out="$(perl -e 'print((stat shift)[9])' "${target}" 2>/dev/null)"
|
|
234
|
+
_mtime_python="$(trusted_python)" || _mtime_python=""
|
|
235
|
+
if [ -n "${_mtime_python}" ]; then
|
|
236
|
+
out="$("${_mtime_python}" -c "import os,sys;print(int(os.stat(sys.argv[1]).st_mtime))" "${target}" 2>/dev/null)"
|
|
197
237
|
if [ -n "${out}" ]; then printf "%s\n" "${out}"; return 0; fi
|
|
198
238
|
fi
|
|
199
239
|
printf "0\n"
|