planning-with-files 3.9.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 +131 -0
- package/SKILL.md +262 -0
- package/examples.md +202 -0
- package/extensions/planning-with-files/README.md +35 -0
- package/extensions/planning-with-files/__tests__/attestation.test.ts +79 -0
- package/extensions/planning-with-files/__tests__/plan-anchor.test.ts +228 -0
- package/extensions/planning-with-files/__tests__/runtime.test.ts +688 -0
- package/extensions/planning-with-files/attestation.ts +55 -0
- package/extensions/planning-with-files/constants.ts +31 -0
- package/extensions/planning-with-files/index.ts +6 -0
- package/extensions/planning-with-files/package.json +17 -0
- package/extensions/planning-with-files/plan.ts +263 -0
- package/extensions/planning-with-files/runtime.ts +788 -0
- package/package.json +46 -0
- package/reference.md +218 -0
- package/scripts/attest-plan.ps1 +137 -0
- package/scripts/attest-plan.sh +206 -0
- package/scripts/check-complete.ps1 +253 -0
- package/scripts/check-complete.sh +253 -0
- package/scripts/init-session.ps1 +230 -0
- package/scripts/init-session.sh +370 -0
- package/scripts/plan-doctor.sh +148 -0
- package/scripts/resolve-plan-dir.ps1 +106 -0
- package/scripts/resolve-plan-dir.sh +263 -0
- package/scripts/session-catchup.py +876 -0
- package/scripts/set-active-plan.ps1 +51 -0
- package/scripts/set-active-plan.sh +50 -0
- package/templates/analytics_findings.md +85 -0
- package/templates/analytics_task_plan.md +106 -0
- package/templates/findings.md +95 -0
- package/templates/progress.md +114 -0
- package/templates/task_plan.md +140 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# planning-with-files: plan-doctor — one-pass self-check for the mechanisms
|
|
3
|
+
# that fail silently. Run from the project root:
|
|
4
|
+
#
|
|
5
|
+
# sh scripts/plan-doctor.sh
|
|
6
|
+
#
|
|
7
|
+
# Answers:
|
|
8
|
+
# - does plan resolution work here, and which plan wins?
|
|
9
|
+
# - does hook injection actually emit plan context?
|
|
10
|
+
# - is the canonicalizer producing comparable paths? (Windows-native
|
|
11
|
+
# coreutils emit C:\-style output; pwf versions before v3.6.0 went
|
|
12
|
+
# silently dark on such machines)
|
|
13
|
+
# - is the plan attested, and is the attestation file where hooks look?
|
|
14
|
+
# - which install surfaces exist on this machine?
|
|
15
|
+
# - what does one hook fire cost in wall-clock?
|
|
16
|
+
#
|
|
17
|
+
# Diagnostic only. Writes nothing except inject-plan.sh's own SHA cache.
|
|
18
|
+
# Always exits 0.
|
|
19
|
+
|
|
20
|
+
set -u
|
|
21
|
+
|
|
22
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd 2>/dev/null)" || SCRIPT_DIR="."
|
|
23
|
+
|
|
24
|
+
ok() { printf 'PASS %s\n' "$1"; }
|
|
25
|
+
warn() { printf 'WARN %s\n' "$1"; }
|
|
26
|
+
fail() { printf 'FAIL %s\n' "$1"; }
|
|
27
|
+
info() { printf 'info %s\n' "$1"; }
|
|
28
|
+
|
|
29
|
+
echo '=== planning-with-files plan-doctor ==='
|
|
30
|
+
info "cwd: ${PWD}"
|
|
31
|
+
info "uname: $(uname -s 2>/dev/null || echo unknown)"
|
|
32
|
+
[ "${PLANNING_DISABLED:-}" = "1" ] && warn "PLANNING_DISABLED=1 is set — every hook exits immediately in this environment"
|
|
33
|
+
|
|
34
|
+
# --- [1] canonicalizer probe -------------------------------------------------
|
|
35
|
+
CANON="$(realpath . 2>/dev/null)" || CANON=""
|
|
36
|
+
[ -z "${CANON}" ] && { CANON="$(readlink -f . 2>/dev/null)" || CANON=""; }
|
|
37
|
+
case "${CANON}" in
|
|
38
|
+
'')
|
|
39
|
+
warn "no realpath/readlink canonicalizer answered — containment falls back to a python spawn per check"
|
|
40
|
+
;;
|
|
41
|
+
*\\*)
|
|
42
|
+
info "canonicalizer emits Windows-style paths (${CANON}) — handled since v3.6.0; OLDER pwf versions resolve nothing on this machine"
|
|
43
|
+
;;
|
|
44
|
+
*)
|
|
45
|
+
info "canonicalizer: ${CANON}"
|
|
46
|
+
;;
|
|
47
|
+
esac
|
|
48
|
+
|
|
49
|
+
# --- [2] plan resolution -----------------------------------------------------
|
|
50
|
+
RES=""
|
|
51
|
+
if [ -f "${SCRIPT_DIR}/resolve-plan-dir.sh" ]; then
|
|
52
|
+
RES="$(sh "${SCRIPT_DIR}/resolve-plan-dir.sh" 2>/dev/null)" || RES=""
|
|
53
|
+
if [ -n "${RES}" ]; then
|
|
54
|
+
ok "resolver: active plan dir = ${RES}"
|
|
55
|
+
elif [ -f task_plan.md ]; then
|
|
56
|
+
ok "resolver: legacy root plan (./task_plan.md)"
|
|
57
|
+
elif [ -d .planning ]; then
|
|
58
|
+
fail "resolver: .planning/ exists but nothing resolves — check .planning/.active_plan content and that plan dirs contain task_plan.md"
|
|
59
|
+
else
|
|
60
|
+
info "resolver: no plan in this directory (run init-session.sh to create one)"
|
|
61
|
+
fi
|
|
62
|
+
else
|
|
63
|
+
warn "resolve-plan-dir.sh not found next to plan-doctor — unexpected install layout"
|
|
64
|
+
fi
|
|
65
|
+
|
|
66
|
+
# --- [3] hook injection ------------------------------------------------------
|
|
67
|
+
INJ="${SCRIPT_DIR}/inject-plan.sh"
|
|
68
|
+
if [ -f "${INJ}" ]; then
|
|
69
|
+
OUT="$(sh "${INJ}" --context=userprompt 2>/dev/null)" || OUT=""
|
|
70
|
+
if [ -z "${OUT}" ]; then
|
|
71
|
+
if [ -n "${RES}" ] || [ -f task_plan.md ]; then
|
|
72
|
+
fail "injection: a plan resolves but inject-plan.sh emitted NOTHING — hooks are dark. Known silent causes: pre-v3.6.0 with a Windows-native realpath on PATH; PLANNING_DISABLED=1; a plan dir outside the project root; a stale .planning/sessions/ dir with no attached session (silences pretool/precompact fires entirely — the userprompt fire names it)."
|
|
73
|
+
else
|
|
74
|
+
ok "injection: silent because no plan exists here (correct behavior)"
|
|
75
|
+
fi
|
|
76
|
+
else
|
|
77
|
+
case "${OUT}" in
|
|
78
|
+
*'PLAN TAMPERED'*)
|
|
79
|
+
warn "injection: plan is attested but the hash mismatches — run /plan-attest (or scripts/attest-plan.sh) to re-approve the current plan"
|
|
80
|
+
;;
|
|
81
|
+
*'requires attested plan'*)
|
|
82
|
+
warn "injection: v3 mode without attestation — run attest-plan once to arm injection"
|
|
83
|
+
;;
|
|
84
|
+
*'Session isolation is armed'*)
|
|
85
|
+
# Refusal notice, not plan context: reporting its byte count as
|
|
86
|
+
# PASS told a dark user their hooks were fine.
|
|
87
|
+
warn "injection: session isolation refuses this session — attach it with PWF_SESSION_ID=<id> plus .planning/sessions/<id>.attached, or delete the .planning/sessions/ dir (stale ones survive earlier Codex use and copied project trees) to turn isolation off"
|
|
88
|
+
;;
|
|
89
|
+
*'Ambiguous plan'*)
|
|
90
|
+
warn "injection: nested-plan ambiguity — a project directly below this cwd carries its own plan, so hooks refuse to guess. Pin the thread with PWF_PLAN_ROOT=<absolute project root> or PLAN_ID=<slug>"
|
|
91
|
+
;;
|
|
92
|
+
*'PWF_PLAN_ROOT is not a directory'*)
|
|
93
|
+
warn "injection: PWF_PLAN_ROOT points at a missing directory — fix or unset the pin; a broken pin fails closed and injects nothing"
|
|
94
|
+
;;
|
|
95
|
+
*)
|
|
96
|
+
BYTES="$(printf '%s' "${OUT}" | wc -c | tr -d '[:space:]')"
|
|
97
|
+
ok "injection: emits plan context (${BYTES} bytes)"
|
|
98
|
+
;;
|
|
99
|
+
esac
|
|
100
|
+
fi
|
|
101
|
+
else
|
|
102
|
+
warn "inject-plan.sh not found next to plan-doctor — this install route ships no hook payload (see the install matrix in docs/installation.md)"
|
|
103
|
+
fi
|
|
104
|
+
|
|
105
|
+
# --- [4] attestation ---------------------------------------------------------
|
|
106
|
+
ATT=""
|
|
107
|
+
if [ -n "${RES}" ] && [ -f "${RES}/.attestation" ]; then
|
|
108
|
+
ATT="${RES}/.attestation"
|
|
109
|
+
elif [ -f .plan-attestation ]; then
|
|
110
|
+
ATT=".plan-attestation"
|
|
111
|
+
fi
|
|
112
|
+
if [ -n "${ATT}" ]; then
|
|
113
|
+
info "attestation present: ${ATT}"
|
|
114
|
+
else
|
|
115
|
+
info "attestation: none (opt-in in legacy mode; default-on in v3 modes; run /plan-attest after approving the plan)"
|
|
116
|
+
fi
|
|
117
|
+
|
|
118
|
+
# --- [5] install surfaces ----------------------------------------------------
|
|
119
|
+
FOUND_SURFACE=0
|
|
120
|
+
for s in \
|
|
121
|
+
".claude/skills/planning-with-files" \
|
|
122
|
+
"${HOME:-}/.claude/skills/planning-with-files" \
|
|
123
|
+
".agents/skills/planning-with-files" \
|
|
124
|
+
"${HOME:-}/.agents/skills/planning-with-files"
|
|
125
|
+
do
|
|
126
|
+
[ -n "${s}" ] && [ -d "${s}" ] && { info "install surface present: ${s}"; FOUND_SURFACE=1; }
|
|
127
|
+
done
|
|
128
|
+
[ "${FOUND_SURFACE}" = "0" ] && info "no skill-dir install surface in project or home (plugin-route installs live under the plugin cache instead)"
|
|
129
|
+
info "route reminder: the plugin route ships commands/ + hooks; npx-skills ships the skill only. Hooks silent after a project-level skill install? Check project trust (hasTrustDialogAccepted) and the install matrix in docs/installation.md."
|
|
130
|
+
|
|
131
|
+
# --- [6] hook latency --------------------------------------------------------
|
|
132
|
+
if [ -f "${INJ}" ]; then
|
|
133
|
+
T0="$(date +%s%N 2>/dev/null)" || T0=""
|
|
134
|
+
sh "${INJ}" --context=userprompt >/dev/null 2>&1
|
|
135
|
+
T1="$(date +%s%N 2>/dev/null)" || T1=""
|
|
136
|
+
case "${T0}${T1}" in
|
|
137
|
+
''|*[!0-9]*)
|
|
138
|
+
info "hook latency: skipped (no nanosecond clock on this date binary)"
|
|
139
|
+
;;
|
|
140
|
+
*)
|
|
141
|
+
MS=$(( (T1 - T0) / 1000000 ))
|
|
142
|
+
info "one inject-plan.sh fire: ${MS}ms wall-clock"
|
|
143
|
+
;;
|
|
144
|
+
esac
|
|
145
|
+
fi
|
|
146
|
+
|
|
147
|
+
echo '=== plan-doctor done ==='
|
|
148
|
+
exit 0
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# planning-with-files: resolve active plan directory (PowerShell mirror).
|
|
2
|
+
#
|
|
3
|
+
# Resolution order matches scripts/resolve-plan-dir.sh:
|
|
4
|
+
# 1. $env:PLAN_ID -> .\.planning\$PLAN_ID\
|
|
5
|
+
# 2. .\.planning\.active_plan content
|
|
6
|
+
# 3. Newest .\.planning\<dir>\ by LastWriteTime
|
|
7
|
+
# 4. Empty (legacy fallback to .\task_plan.md handled by caller)
|
|
8
|
+
#
|
|
9
|
+
# v3.8.0 parity with the sh resolver: slug validation on every branch, the
|
|
10
|
+
# newest-dir scan requires task_plan.md inside the candidate (a sessions/ or
|
|
11
|
+
# artifacts/ dir must never win), and containment fails CLOSED when
|
|
12
|
+
# canonicalization fails. Only successful canonicalization can rule out a
|
|
13
|
+
# junction/symlink escape; slug validation alone blocks textual traversal.
|
|
14
|
+
|
|
15
|
+
param(
|
|
16
|
+
[string]$PlanRoot = (Join-Path (Get-Location) ".planning")
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
$projectRoot = (Get-Location).Path
|
|
20
|
+
|
|
21
|
+
# PWF_PLAN_ROOT: absolute plan-root binding (issue #212), mirroring
|
|
22
|
+
# resolve-plan-dir.sh. A thread whose cwd is a shared PARENT of the real
|
|
23
|
+
# project resolves the parent's plan and never sees the nested one;
|
|
24
|
+
# PWF_PLAN_ROOT names the project root whose .planning must be used. Highest
|
|
25
|
+
# precedence: it overrides both the cwd default and the -PlanRoot argument
|
|
26
|
+
# (an adapter passing ".planning" is spelling out the cwd default, not
|
|
27
|
+
# overriding a user's deliberate pin). A pin that is not a directory fails
|
|
28
|
+
# CLOSED: the resolver emits nothing, so no caller can be handed the
|
|
29
|
+
# ambiguous cwd plan the pin was escaping (injection routes own the
|
|
30
|
+
# user-facing notice; stdout here is the data channel). Containment is then
|
|
31
|
+
# checked against the pinned root. Unset keeps legacy behavior unchanged.
|
|
32
|
+
if ($env:PWF_PLAN_ROOT) {
|
|
33
|
+
if (Test-Path -LiteralPath $env:PWF_PLAN_ROOT -PathType Container) {
|
|
34
|
+
$projectRoot = $env:PWF_PLAN_ROOT
|
|
35
|
+
$PlanRoot = Join-Path $env:PWF_PLAN_ROOT ".planning"
|
|
36
|
+
} else {
|
|
37
|
+
exit 0
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
# Same shape as the sh resolver's slug_is_valid: first char [A-Za-z0-9_],
|
|
42
|
+
# rest [A-Za-z0-9._-]. Blocks traversal tokens before any path is built.
|
|
43
|
+
function Test-ValidSlug {
|
|
44
|
+
param([string]$Name)
|
|
45
|
+
if (-not $Name) { return $false }
|
|
46
|
+
return $Name -match '^[A-Za-z0-9_][A-Za-z0-9._-]*$'
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
# Containment guard (security A1.3): a resolved plan dir must canonicalize to
|
|
50
|
+
# a path under the project root. A directory symlink/junction inside a valid
|
|
51
|
+
# slug pointing outside the workspace would otherwise let the hooks hash and
|
|
52
|
+
# inject an arbitrary file. Resolve-Path follows reparse points; we compare
|
|
53
|
+
# the real paths. Fails CLOSED on canonicalization failure, matching
|
|
54
|
+
# resolve-plan-dir.sh.
|
|
55
|
+
function Test-WithinRoot {
|
|
56
|
+
param([string]$Candidate)
|
|
57
|
+
try {
|
|
58
|
+
$rootReal = (Resolve-Path -LiteralPath $projectRoot -ErrorAction Stop).Path
|
|
59
|
+
$candReal = (Resolve-Path -LiteralPath $Candidate -ErrorAction Stop).Path
|
|
60
|
+
} catch {
|
|
61
|
+
return $false
|
|
62
|
+
}
|
|
63
|
+
if (-not $rootReal -or -not $candReal) { return $false }
|
|
64
|
+
$rootNorm = $rootReal.TrimEnd('\', '/')
|
|
65
|
+
$candNorm = $candReal.TrimEnd('\', '/')
|
|
66
|
+
if ($candNorm -eq $rootNorm) { return $true }
|
|
67
|
+
return $candNorm.StartsWith($rootNorm + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
$activeFile = Join-Path $PlanRoot ".active_plan"
|
|
71
|
+
|
|
72
|
+
if ($env:PLAN_ID) {
|
|
73
|
+
if (Test-ValidSlug $env:PLAN_ID) {
|
|
74
|
+
$candidate = Join-Path $PlanRoot $env:PLAN_ID
|
|
75
|
+
if ((Test-Path $candidate -PathType Container) -and (Test-WithinRoot $candidate)) {
|
|
76
|
+
Write-Output $candidate
|
|
77
|
+
exit 0
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (Test-Path $activeFile) {
|
|
83
|
+
$planId = (Get-Content $activeFile -Raw).Trim()
|
|
84
|
+
if ($planId -and (Test-ValidSlug $planId)) {
|
|
85
|
+
$candidate = Join-Path $PlanRoot $planId
|
|
86
|
+
if ((Test-Path $candidate -PathType Container) -and (Test-WithinRoot $candidate)) {
|
|
87
|
+
Write-Output $candidate
|
|
88
|
+
exit 0
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (Test-Path $PlanRoot -PathType Container) {
|
|
94
|
+
$latest = Get-ChildItem -Path $PlanRoot -Directory |
|
|
95
|
+
Where-Object { -not $_.Name.StartsWith('.') } |
|
|
96
|
+
Where-Object { Test-ValidSlug $_.Name } |
|
|
97
|
+
Where-Object { Test-Path (Join-Path $_.FullName "task_plan.md") -PathType Leaf } |
|
|
98
|
+
Where-Object { Test-WithinRoot $_.FullName } |
|
|
99
|
+
Sort-Object LastWriteTime -Descending |
|
|
100
|
+
Select-Object -First 1
|
|
101
|
+
if ($latest) {
|
|
102
|
+
Write-Output $latest.FullName
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
exit 0
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# planning-with-files: resolve active plan directory.
|
|
3
|
+
#
|
|
4
|
+
# Resolution order:
|
|
5
|
+
# 1. $PLAN_ID env var → ./.planning/$PLAN_ID/ if exists
|
|
6
|
+
# 2. ./.planning/.active_plan content → matching dir if exists
|
|
7
|
+
# 3. Newest ./.planning/<dir>/ by mtime
|
|
8
|
+
# 4. Otherwise empty stdout (caller falls back to legacy ./task_plan.md)
|
|
9
|
+
#
|
|
10
|
+
# Always exits 0. Never errors out the agent loop.
|
|
11
|
+
#
|
|
12
|
+
# Usage:
|
|
13
|
+
# PLAN_DIR="$(sh scripts/resolve-plan-dir.sh)"
|
|
14
|
+
# PLAN_FILE="${PLAN_DIR:+$PLAN_DIR/}task_plan.md"
|
|
15
|
+
|
|
16
|
+
set -u
|
|
17
|
+
|
|
18
|
+
PLAN_ROOT="${1:-${PWD}/.planning}"
|
|
19
|
+
|
|
20
|
+
# --- PWF_PLAN_ROOT: absolute plan-root binding (issue #212). ---
|
|
21
|
+
# A thread whose cwd is a shared PARENT of the real project (e.g. /workspace
|
|
22
|
+
# holding /workspace/project with its own .planning) resolves the parent's
|
|
23
|
+
# plan on every call and never sees the nested one. PWF_PLAN_ROOT names the
|
|
24
|
+
# project root whose .planning must be used. It is the highest-precedence
|
|
25
|
+
# binding: it overrides both the ${PWD} default and the positional argument,
|
|
26
|
+
# because an adapter passing ".planning" is spelling out the cwd default, not
|
|
27
|
+
# overriding a user's deliberate pin. A pin that is not a directory fails
|
|
28
|
+
# CLOSED: the resolver emits nothing, so no caller can be handed the
|
|
29
|
+
# ambiguous cwd plan the pin was escaping (the injection routes own the
|
|
30
|
+
# user-facing notice; stdout here is the data channel and must stay clean).
|
|
31
|
+
# With the variable unset, behavior is byte-identical to the legacy shape.
|
|
32
|
+
PWF_ROOT_PIN=""
|
|
33
|
+
if [ -n "${PWF_PLAN_ROOT:-}" ]; then
|
|
34
|
+
if [ -d "${PWF_PLAN_ROOT}" ]; then
|
|
35
|
+
PWF_ROOT_PIN="${PWF_PLAN_ROOT}"
|
|
36
|
+
PLAN_ROOT="${PWF_PLAN_ROOT}/.planning"
|
|
37
|
+
else
|
|
38
|
+
exit 0
|
|
39
|
+
fi
|
|
40
|
+
fi
|
|
41
|
+
|
|
42
|
+
ACTIVE_FILE="${PLAN_ROOT}/.active_plan"
|
|
43
|
+
|
|
44
|
+
# Plan-id safe-identifier check. Rejects whitespace, path separators, leading
|
|
45
|
+
# dots, and empty strings; accepts the YYYY-MM-DD-<slug> shape from
|
|
46
|
+
# init-session.sh as well as legacy hand-created names like "alpha" or
|
|
47
|
+
# "feature-foo". The intent is to filter garbage content (e.g. a corrupt
|
|
48
|
+
# .active_plan file containing only whitespace or random text) without
|
|
49
|
+
# enforcing a date prefix that would break backward compatibility.
|
|
50
|
+
# Pure-sh case patterns; semantics match the previous
|
|
51
|
+
# grep -E '^[A-Za-z0-9_][A-Za-z0-9._-]*$' exactly, without a grep fork per
|
|
52
|
+
# candidate (the newest-mtime scan calls this once per plan dir).
|
|
53
|
+
slug_is_valid() {
|
|
54
|
+
case "$1" in
|
|
55
|
+
'') return 1 ;;
|
|
56
|
+
*[!A-Za-z0-9._-]*) return 1 ;;
|
|
57
|
+
[A-Za-z0-9_]*) return 0 ;;
|
|
58
|
+
esac
|
|
59
|
+
return 1
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
# Pure-sh backslash-to-forward-slash normalizer; result lands in $NORM_OUT.
|
|
63
|
+
# Windows-native coreutils builds (e.g. C:\Program Files\coreutils on PATH
|
|
64
|
+
# ahead of Git's usr/bin) canonicalize MSYS-style /c/... input to C:\-style
|
|
65
|
+
# backslash output. The containment prefix match below is written with forward
|
|
66
|
+
# slashes, so without this normalization every canonical pair mismatches and
|
|
67
|
+
# resolution silently fails. On POSIX systems paths contain no backslash and
|
|
68
|
+
# this is the identity. A literal backslash in a Unix filename normalizes to
|
|
69
|
+
# "/" and at worst fails containment — the safe direction. No subshell, no
|
|
70
|
+
# fork: plain parameter expansion in a loop.
|
|
71
|
+
norm_slashes() {
|
|
72
|
+
NORM_OUT=""
|
|
73
|
+
_ns_rest="$1"
|
|
74
|
+
while :; do
|
|
75
|
+
case "${_ns_rest}" in
|
|
76
|
+
*\\*)
|
|
77
|
+
NORM_OUT="${NORM_OUT}${_ns_rest%%\\*}/"
|
|
78
|
+
_ns_rest="${_ns_rest#*\\}"
|
|
79
|
+
;;
|
|
80
|
+
*)
|
|
81
|
+
NORM_OUT="${NORM_OUT}${_ns_rest}"
|
|
82
|
+
break
|
|
83
|
+
;;
|
|
84
|
+
esac
|
|
85
|
+
done
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
# Portable path canonicalizer. realpath first (Linux, modern coreutils),
|
|
89
|
+
# then readlink -f (older GNU), then python3/python os.path.realpath. Prints
|
|
90
|
+
# the canonical absolute path on success; prints nothing and returns 1 on a
|
|
91
|
+
# full miss so the caller can decide what to do. No python spawn on the happy
|
|
92
|
+
# path: realpath/readlink cover Linux, WSL, Git-Bash, and modern macOS.
|
|
93
|
+
canonicalize() {
|
|
94
|
+
target="$1"
|
|
95
|
+
if command -v realpath >/dev/null 2>&1; then
|
|
96
|
+
out="$(realpath "${target}" 2>/dev/null)" && [ -n "${out}" ] && {
|
|
97
|
+
printf "%s\n" "${out}"; return 0; }
|
|
98
|
+
fi
|
|
99
|
+
if command -v readlink >/dev/null 2>&1; then
|
|
100
|
+
out="$(readlink -f "${target}" 2>/dev/null)" && [ -n "${out}" ] && {
|
|
101
|
+
printf "%s\n" "${out}"; return 0; }
|
|
102
|
+
fi
|
|
103
|
+
if command -v python3 >/dev/null 2>&1; then
|
|
104
|
+
out="$(python3 -c "import os,sys;print(os.path.realpath(sys.argv[1]))" "${target}" 2>/dev/null)" \
|
|
105
|
+
&& [ -n "${out}" ] && { printf "%s\n" "${out}"; return 0; }
|
|
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)" \
|
|
109
|
+
&& [ -n "${out}" ] && { printf "%s\n" "${out}"; return 0; }
|
|
110
|
+
fi
|
|
111
|
+
return 1
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
# Containment guard (security A1.3): a resolved plan dir must canonicalize to a
|
|
115
|
+
# path under the project root (the CWD the script runs from). A symlink inside
|
|
116
|
+
# a valid slug dir pointing at /etc or outside the workspace would otherwise let
|
|
117
|
+
# the hooks hash and inject an arbitrary file. On any violation we return 1 so
|
|
118
|
+
# the caller treats the candidate as unresolved and falls back safely.
|
|
119
|
+
#
|
|
120
|
+
# The root canonicalizes via the relative token "." rather than the $PWD
|
|
121
|
+
# string. On some Windows/MSYS setups (8.3 short names, the /tmp mount alias)
|
|
122
|
+
# realpath("$PWD") and realpath(relative-candidate) resolve through different
|
|
123
|
+
# code paths and land on differently-spelled-but-equal targets, so the prefix
|
|
124
|
+
# match below fails and resolution silently goes dark. "." resolves through
|
|
125
|
+
# the same physical-cwd path candidates already use (same fix inject-plan.sh
|
|
126
|
+
# received earlier; the resolver kept the $PWD form until now). Both sides are
|
|
127
|
+
# backslash-normalized before comparison for Windows-native canonicalizers.
|
|
128
|
+
# The root is computed once per run: the newest-mtime scan calls this guard
|
|
129
|
+
# per plan dir, and each canonicalize costs a process spawn on Windows.
|
|
130
|
+
#
|
|
131
|
+
# With a PWF_PLAN_ROOT pin (issue #212) containment is checked against THAT
|
|
132
|
+
# root instead of the cwd: candidates arrive ${PWF_PLAN_ROOT}/-prefixed, so
|
|
133
|
+
# both sides canonicalize through the same path spelling. Unpinned keeps the
|
|
134
|
+
# relative "." root — byte-identical to the legacy check.
|
|
135
|
+
ROOT_REAL=""
|
|
136
|
+
ROOT_REAL_SET=0
|
|
137
|
+
is_within_root() {
|
|
138
|
+
candidate="$1"
|
|
139
|
+
if [ "${ROOT_REAL_SET}" = "0" ]; then
|
|
140
|
+
ROOT_REAL="$(canonicalize "${PWF_ROOT_PIN:-.}")" || ROOT_REAL=""
|
|
141
|
+
norm_slashes "${ROOT_REAL}"
|
|
142
|
+
ROOT_REAL="${NORM_OUT}"
|
|
143
|
+
ROOT_REAL_SET=1
|
|
144
|
+
fi
|
|
145
|
+
# Canonicalize the candidate through its cwd-RELATIVE form whenever it
|
|
146
|
+
# lives under ${PWD}. The candidate string is built from ${PWD} (an MSYS
|
|
147
|
+
# long-form spelling), while the root canonicalizes from "." (the process
|
|
148
|
+
# cwd, which a caller may have set with an 8.3 short-form string). A
|
|
149
|
+
# Windows-native realpath does not unify those spellings, so canonicalizing
|
|
150
|
+
# both sides from the same cwd base is the only spelling-stable comparison.
|
|
151
|
+
# The emitted result keeps the original absolute candidate — only the
|
|
152
|
+
# containment check uses the relative form.
|
|
153
|
+
# Pinned resolution skips the rewrite: candidate and root then share the
|
|
154
|
+
# ${PWF_PLAN_ROOT} spelling, so both canonicalize directly from it.
|
|
155
|
+
if [ -n "${PWF_ROOT_PIN}" ]; then
|
|
156
|
+
check_target="${candidate}"
|
|
157
|
+
else
|
|
158
|
+
case "${candidate}" in
|
|
159
|
+
"${PWD}"/*) check_target=".${candidate#"${PWD}"}" ;;
|
|
160
|
+
*) check_target="${candidate}" ;;
|
|
161
|
+
esac
|
|
162
|
+
fi
|
|
163
|
+
cand_real="$(canonicalize "${check_target}")" || cand_real=""
|
|
164
|
+
norm_slashes "${cand_real}"
|
|
165
|
+
cand_real="${NORM_OUT}"
|
|
166
|
+
if [ -z "${ROOT_REAL}" ] || [ -z "${cand_real}" ]; then
|
|
167
|
+
# Slug validation blocks textual traversal, but only successful
|
|
168
|
+
# canonicalization can rule out a symlink/junction escape.
|
|
169
|
+
return 1
|
|
170
|
+
fi
|
|
171
|
+
case "${cand_real}" in
|
|
172
|
+
"${ROOT_REAL}"|"${ROOT_REAL}"/*) return 0 ;;
|
|
173
|
+
*) return 1 ;;
|
|
174
|
+
esac
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
# Portable mtime resolver. Tries GNU stat, BSD stat, BSD/macOS date -r,
|
|
178
|
+
# python3, then perl. Returns "0" on full miss so callers can sort.
|
|
179
|
+
mtime_of() {
|
|
180
|
+
target="$1"
|
|
181
|
+
out="$(stat -c '%Y' "${target}" 2>/dev/null)"
|
|
182
|
+
if [ -n "${out}" ]; then printf "%s\n" "${out}"; return 0; fi
|
|
183
|
+
out="$(stat -f '%m' "${target}" 2>/dev/null)"
|
|
184
|
+
if [ -n "${out}" ]; then printf "%s\n" "${out}"; return 0; fi
|
|
185
|
+
out="$(date -r "${target}" +%s 2>/dev/null)"
|
|
186
|
+
if [ -n "${out}" ]; then printf "%s\n" "${out}"; return 0; fi
|
|
187
|
+
if command -v python3 >/dev/null 2>&1; then
|
|
188
|
+
out="$(python3 -c "import os,sys;print(int(os.stat(sys.argv[1]).st_mtime))" "${target}" 2>/dev/null)"
|
|
189
|
+
if [ -n "${out}" ]; then printf "%s\n" "${out}"; return 0; fi
|
|
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)"
|
|
197
|
+
if [ -n "${out}" ]; then printf "%s\n" "${out}"; return 0; fi
|
|
198
|
+
fi
|
|
199
|
+
printf "0\n"
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
resolve_from_env() {
|
|
203
|
+
plan_id="${PLAN_ID:-}"
|
|
204
|
+
slug_is_valid "${plan_id}" || return 1
|
|
205
|
+
candidate="${PLAN_ROOT}/${plan_id}"
|
|
206
|
+
if [ -d "${candidate}" ] && is_within_root "${candidate}"; then
|
|
207
|
+
printf "%s\n" "${candidate}"
|
|
208
|
+
return 0
|
|
209
|
+
fi
|
|
210
|
+
return 1
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
resolve_from_active_file() {
|
|
214
|
+
[ -f "${ACTIVE_FILE}" ] || return 1
|
|
215
|
+
plan_id="$(tr -d '\r\n[:space:]' < "${ACTIVE_FILE}")"
|
|
216
|
+
# UTF-8 BOM is not part of the plan id. POSIX printf octal escapes keep
|
|
217
|
+
# this portable across GNU/BSD sed variants and Git-for-Windows sh.
|
|
218
|
+
utf8_bom="$(printf '\357\273\277')"
|
|
219
|
+
case "${plan_id}" in
|
|
220
|
+
"${utf8_bom}"*) plan_id="${plan_id#"${utf8_bom}"}" ;;
|
|
221
|
+
esac
|
|
222
|
+
slug_is_valid "${plan_id}" || return 1
|
|
223
|
+
candidate="${PLAN_ROOT}/${plan_id}"
|
|
224
|
+
if [ -d "${candidate}" ] && is_within_root "${candidate}"; then
|
|
225
|
+
printf "%s\n" "${candidate}"
|
|
226
|
+
return 0
|
|
227
|
+
fi
|
|
228
|
+
return 1
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
resolve_latest_dir() {
|
|
232
|
+
[ -d "${PLAN_ROOT}" ] || return 1
|
|
233
|
+
# Portable newest-mtime selector. Skips hidden dirs, slug-invalid names,
|
|
234
|
+
# and dirs without task_plan.md (e.g. sessions/).
|
|
235
|
+
latest=""
|
|
236
|
+
latest_mtime=0
|
|
237
|
+
for entry in "${PLAN_ROOT}"/*/; do
|
|
238
|
+
[ -d "${entry}" ] || continue
|
|
239
|
+
clean="${entry%/}"
|
|
240
|
+
name="${clean##*/}"
|
|
241
|
+
case "${name}" in
|
|
242
|
+
.*) continue ;;
|
|
243
|
+
esac
|
|
244
|
+
slug_is_valid "${name}" || continue
|
|
245
|
+
[ -f "${clean}/task_plan.md" ] || continue
|
|
246
|
+
is_within_root "${clean}" || continue
|
|
247
|
+
mtime="$(mtime_of "${clean}")"
|
|
248
|
+
if [ "${mtime}" -gt "${latest_mtime}" ] 2>/dev/null; then
|
|
249
|
+
latest_mtime="${mtime}"
|
|
250
|
+
latest="${clean}"
|
|
251
|
+
fi
|
|
252
|
+
done
|
|
253
|
+
if [ -n "${latest}" ]; then
|
|
254
|
+
printf "%s\n" "${latest}"
|
|
255
|
+
return 0
|
|
256
|
+
fi
|
|
257
|
+
return 1
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if resolve_from_env; then exit 0; fi
|
|
261
|
+
if resolve_from_active_file; then exit 0; fi
|
|
262
|
+
if resolve_latest_dir; then exit 0; fi
|
|
263
|
+
exit 0
|