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.
Files changed (32) hide show
  1. package/README.md +131 -0
  2. package/SKILL.md +262 -0
  3. package/examples.md +202 -0
  4. package/extensions/planning-with-files/README.md +35 -0
  5. package/extensions/planning-with-files/__tests__/attestation.test.ts +79 -0
  6. package/extensions/planning-with-files/__tests__/plan-anchor.test.ts +228 -0
  7. package/extensions/planning-with-files/__tests__/runtime.test.ts +688 -0
  8. package/extensions/planning-with-files/attestation.ts +55 -0
  9. package/extensions/planning-with-files/constants.ts +31 -0
  10. package/extensions/planning-with-files/index.ts +6 -0
  11. package/extensions/planning-with-files/package.json +17 -0
  12. package/extensions/planning-with-files/plan.ts +263 -0
  13. package/extensions/planning-with-files/runtime.ts +788 -0
  14. package/package.json +46 -0
  15. package/reference.md +218 -0
  16. package/scripts/attest-plan.ps1 +137 -0
  17. package/scripts/attest-plan.sh +206 -0
  18. package/scripts/check-complete.ps1 +253 -0
  19. package/scripts/check-complete.sh +253 -0
  20. package/scripts/init-session.ps1 +230 -0
  21. package/scripts/init-session.sh +370 -0
  22. package/scripts/plan-doctor.sh +148 -0
  23. package/scripts/resolve-plan-dir.ps1 +106 -0
  24. package/scripts/resolve-plan-dir.sh +263 -0
  25. package/scripts/session-catchup.py +876 -0
  26. package/scripts/set-active-plan.ps1 +51 -0
  27. package/scripts/set-active-plan.sh +50 -0
  28. package/templates/analytics_findings.md +85 -0
  29. package/templates/analytics_task_plan.md +106 -0
  30. package/templates/findings.md +95 -0
  31. package/templates/progress.md +114 -0
  32. 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