planning-with-files 3.18.3 → 3.20.5

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.
@@ -83,7 +83,8 @@ done
83
83
 
84
84
  DATE=$(date +%Y-%m-%d)
85
85
 
86
- SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
86
+ # CDPATH must not redirect the cd that locates the sibling scripts.
87
+ SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
87
88
  SKILL_ROOT="$(dirname "$SCRIPT_DIR")"
88
89
  TEMPLATE_DIR="$SKILL_ROOT/templates"
89
90
 
@@ -102,6 +103,7 @@ slugify() {
102
103
  # Lowercase, non-alphanumerics → '-', collapse repeats, trim leading/trailing '-'
103
104
  printf '%s' "$1" \
104
105
  | tr '[:upper:]' '[:lower:]' \
106
+ | tr '\r\n' '--' \
105
107
  | sed -e 's/[^a-z0-9]/-/g' -e 's/-\{2,\}/-/g' -e 's/^-//' -e 's/-$//' \
106
108
  | cut -c1-40
107
109
  }
@@ -184,6 +186,10 @@ apply_v3_mode() {
184
186
  _mode_plan="$2"
185
187
  [ -z "$MODE" ] && return 0
186
188
 
189
+ ATTESTATION_OK=0
190
+ ATTESTATION_COMMAND="attest-plan.sh"
191
+ ATTESTATION_REASON="task_plan.md was not available for attestation"
192
+
187
193
  # (a) reset the gate block counter and drop any stale gate ledger so a prior
188
194
  # run's high block count cannot let the next run stop instantly.
189
195
  printf '0\n' > "${_mode_dir}/.stop_blocks"
@@ -201,12 +207,61 @@ apply_v3_mode() {
201
207
 
202
208
  # (c) auto-attest the plan (attestation default-on in v3 modes, security
203
209
  # strand rec 1). attest-plan.sh resolves the same way init-session just
204
- # pinned things: in slug mode PLAN_ID points at this plan dir; in legacy
205
- # mode it is empty and the script falls back to ./task_plan.md at root.
206
- # Run from the project root (CWD here) so both resolutions land.
207
- _attest="${SCRIPT_DIR}/attest-plan.sh"
208
- if [ -f "${_attest}" ] && [ -f "${_mode_plan}" ]; then
209
- PLAN_ID="${PLAN_ID:-}" sh "${_attest}" >/dev/null 2>&1 || true
210
+ # pinned things. Slug mode binds both selectors to the plan that was
211
+ # just created, so an inherited PWF_PLAN_ROOT or PLAN_ID cannot
212
+ # redirect attestation to another project or plan (#261, #237). Root
213
+ # mode clears both instead: the attester only falls back to the legacy
214
+ # ./task_plan.md when no selector is set, and a bound pin would make it
215
+ # refuse the root plan. Run from the project root (CWD here) so both
216
+ # resolutions land.
217
+ _attest="${SCRIPT_DIR}/${ATTESTATION_COMMAND}"
218
+ if [ ! -f "${_attest}" ]; then
219
+ ATTESTATION_REASON="${ATTESTATION_COMMAND} was not found beside init-session.sh"
220
+ return 0
221
+ fi
222
+ if [ ! -f "${_mode_plan}" ]; then
223
+ return 0
224
+ fi
225
+
226
+ if [ "$SLUG_MODE" -eq 1 ]; then
227
+ if _attest_output="$(PWF_PLAN_ROOT="$PWD" PLAN_ID="${PLAN_ID}" sh "${_attest}" 2>&1)"; then
228
+ ATTESTATION_OK=1
229
+ ATTESTATION_REASON=""
230
+ return 0
231
+ else
232
+ _attest_rc=$?
233
+ fi
234
+ else
235
+ if _attest_output="$(PWF_PLAN_ROOT="" PLAN_ID="" sh "${_attest}" 2>&1)"; then
236
+ ATTESTATION_OK=1
237
+ ATTESTATION_REASON=""
238
+ return 0
239
+ else
240
+ _attest_rc=$?
241
+ fi
242
+ fi
243
+
244
+ _attest_reason="$(
245
+ printf '%s\n' "${_attest_output}" |
246
+ sed -n '/[^[:space:]]/ { s/[[:space:]][[:space:]]*/ /g; s/^ //; s/ $//; p; q; }' |
247
+ cut -c1-300
248
+ )"
249
+ if [ -n "${_attest_reason}" ]; then
250
+ ATTESTATION_REASON="${_attest_reason}"
251
+ else
252
+ ATTESTATION_REASON="${ATTESTATION_COMMAND} exited with code ${_attest_rc}"
253
+ fi
254
+ return 0
255
+ }
256
+
257
+ print_v3_mode_status() {
258
+ _status_dir="$1"
259
+ _marker="$(cat "${_status_dir}/.mode")"
260
+ if [ "${ATTESTATION_OK:-0}" -eq 1 ]; then
261
+ printf 'Mode: %s (attested, gate counter reset)\n' "${_marker}"
262
+ else
263
+ printf 'Mode: %s (NOT attested: %s; run %s before the first hook fire)\n' \
264
+ "${_marker}" "${ATTESTATION_REASON:-attestation failed}" "${ATTESTATION_COMMAND:-attest-plan.sh}"
210
265
  fi
211
266
  }
212
267
 
@@ -382,6 +437,20 @@ if [ "$SLUG_MODE" -eq 1 ]; then
382
437
  BASE_ID="${DATE}-${SLUG}"
383
438
  PLAN_ID="$BASE_ID"
384
439
  PLAN_ROOT="${PWD}/.planning"
440
+ PLAN_SELECTOR="${SCRIPT_DIR}/set-active-plan.sh"
441
+ if [ ! -f "${PLAN_SELECTOR}" ]; then
442
+ echo "Error: set-active-plan.sh is required to create a named plan safely." >&2
443
+ exit 1
444
+ fi
445
+ mkdir -p "${PLAN_ROOT}"
446
+ # Verify the physical planning root and the existing pointer before
447
+ # creating anything below it. A symlink or junction that escapes the
448
+ # project must not redirect init writes, and a linked or non-regular
449
+ # pointer must be refused before a plan directory exists on disk. The
450
+ # selector's check is constant time; --list would parse every plan.
451
+ if ! sh "${PLAN_SELECTOR}" --verify-root; then
452
+ exit 1
453
+ fi
385
454
  counter=2
386
455
  while [ -d "${PLAN_ROOT}/${PLAN_ID}" ]; do
387
456
  PLAN_ID="${BASE_ID}-${counter}"
@@ -393,7 +462,13 @@ if [ "$SLUG_MODE" -eq 1 ]; then
393
462
  echo "Initializing planning files for: ${PROJECT_NAME:-untitled} (template: $TEMPLATE)"
394
463
  echo "PLAN_ID=$PLAN_ID"
395
464
  create_files_in "$PLAN_DIR"
396
- printf "%s\n" "$PLAN_ID" > "${PLAN_ROOT}/.active_plan"
465
+ # Reuse the selector's contained, atomic pointer replacement. Direct shell
466
+ # redirection would truncate a pre-existing hardlink and could overwrite a
467
+ # different file that shares the same inode.
468
+ if ! sh "${PLAN_SELECTOR}" "${PLAN_ID}" >/dev/null; then
469
+ echo "Error: could not safely update ${PLAN_ROOT}/.active_plan." >&2
470
+ exit 1
471
+ fi
397
472
  inherit_root_mode
398
473
  apply_v3_mode "$PLAN_DIR" "${PLAN_DIR}/task_plan.md"
399
474
  echo ""
@@ -401,7 +476,7 @@ if [ "$SLUG_MODE" -eq 1 ]; then
401
476
  echo "Pin this terminal to the plan for parallel sessions:"
402
477
  echo " export PLAN_ID=$PLAN_ID"
403
478
  if [ -n "$MODE" ]; then
404
- echo "Mode: $(cat "${PLAN_DIR}/.mode") (attested, gate counter reset)"
479
+ print_v3_mode_status "${PLAN_DIR}"
405
480
  fi
406
481
  else
407
482
  PROJECT_NAME="${PROJECT_NAME:-project}"
@@ -412,6 +487,6 @@ else
412
487
  echo "Planning files initialized!"
413
488
  echo "Files: task_plan.md, findings.md, progress.md"
414
489
  if [ -n "$MODE" ]; then
415
- echo "Mode: $(cat "$(pwd)/.mode") (attested, gate counter reset)"
490
+ print_v3_mode_status "$(pwd)"
416
491
  fi
417
492
  fi
@@ -845,7 +845,10 @@ class Injector(object):
845
845
  )
846
846
  raise Bail()
847
847
  if plan_id:
848
- if slug_is_valid(plan_id) and is_dir(plan_prefix + ".planning/" + plan_id):
848
+ # A linked plan directory is never selectable (#270): the same
849
+ # `[ ! -L ]` the reference applies on every branch below.
850
+ if (slug_is_valid(plan_id) and is_dir(plan_prefix + ".planning/" + plan_id)
851
+ and not is_link(plan_prefix + ".planning/" + plan_id)):
849
852
  resolved = plan_prefix + ".planning/" + plan_id
850
853
  scope = "scoped"
851
854
  explicit = True
@@ -864,7 +867,7 @@ class Injector(object):
864
867
  active = b""
865
868
  if active and slug_is_valid(active):
866
869
  slug = active.decode("ascii")
867
- if is_dir(plan_prefix + ".planning/" + slug):
870
+ if is_dir(plan_prefix + ".planning/" + slug) and not is_link(plan_prefix + ".planning/" + slug):
868
871
  resolved = plan_prefix + ".planning/" + slug
869
872
  scope = "scoped"
870
873
  if not resolved and is_dir(plan_prefix + ".planning"):
@@ -880,6 +883,8 @@ class Injector(object):
880
883
  candidate = plan_prefix + ".planning/" + name
881
884
  if not is_dir(candidate):
882
885
  continue
886
+ if is_link(candidate):
887
+ continue
883
888
  if not slug_is_valid(name):
884
889
  continue
885
890
  if not is_file(candidate + "/task_plan.md"):
@@ -1327,7 +1332,12 @@ def plan_is_ambiguous(plan_root, project_root, plan_id=""):
1327
1332
  except OSError:
1328
1333
  names = []
1329
1334
  for name in names:
1330
- if slug_is_valid(name) and is_file(plan_root + "/" + name + "/task_plan.md"):
1335
+ candidate_dir = plan_root + "/" + name
1336
+ # A linked plan directory is not selectable, so it never counts (#270):
1337
+ # the reference tests `[ -L "$plan_candidate_dir" ]` before `-f`.
1338
+ if is_link(candidate_dir):
1339
+ continue
1340
+ if slug_is_valid(name) and is_file(candidate_dir + "/task_plan.md"):
1331
1341
  count += 1
1332
1342
  if count > 1:
1333
1343
  return True
@@ -1364,7 +1374,7 @@ def resolve_plan_dir(env=None):
1364
1374
  if plan_id:
1365
1375
  if slug_is_valid(plan_id):
1366
1376
  candidate = fs_root + "/" + plan_id
1367
- if is_dir(candidate) and within(candidate):
1377
+ if is_dir(candidate) and not is_link(candidate) and within(candidate):
1368
1378
  return found(plan_id)
1369
1379
  return ("", "")
1370
1380
 
@@ -1382,7 +1392,7 @@ def resolve_plan_dir(env=None):
1382
1392
  if slug_is_valid(active):
1383
1393
  slug = active.decode("ascii")
1384
1394
  candidate = fs_root + "/" + slug
1385
- if is_dir(candidate) and within(candidate):
1395
+ if is_dir(candidate) and not is_link(candidate) and within(candidate):
1386
1396
  return found(slug)
1387
1397
 
1388
1398
  if is_dir(fs_root):
@@ -1398,6 +1408,8 @@ def resolve_plan_dir(env=None):
1398
1408
  continue
1399
1409
  if name.startswith("."):
1400
1410
  continue
1411
+ if is_link(candidate):
1412
+ continue
1401
1413
  if not slug_is_valid(name):
1402
1414
  continue
1403
1415
  if not is_file(candidate + "/task_plan.md"):
@@ -263,8 +263,9 @@ if [ -z "${PLAN_ID:-}" ]; then
263
263
  PLAN_COUNT=1
264
264
  fi
265
265
  for plan_candidate in "${PLAN_PREFIX}".planning/*/task_plan.md; do
266
- [ -f "$plan_candidate" ] || continue
267
266
  plan_candidate_dir="${plan_candidate%/task_plan.md}"
267
+ [ -L "$plan_candidate_dir" ] && continue
268
+ [ -f "$plan_candidate" ] || continue
268
269
  slug_is_valid "${plan_candidate_dir##*/}" || continue
269
270
  PLAN_COUNT=$((PLAN_COUNT + 1))
270
271
  if [ "$PLAN_COUNT" -gt 1 ]; then PLAN_AMBIGUOUS=1; break; fi
@@ -295,7 +296,9 @@ if [ -n "${PLAN_ID:-}" ]; then
295
296
  # printing on those would spam the transcript with the same line. The
296
297
  # userprompt fire is also the one plan-doctor.sh drives, so /plan-doctor
297
298
  # still sees and reports the state.
298
- if slug_is_valid "$PLAN_ID" && [ -d "${PLAN_PREFIX}.planning/${PLAN_ID}" ]; then
299
+ # A linked plan directory is never selectable (#270): same `-L` rule as
300
+ # the counter above and as resolve-plan-dir.sh, on every branch below.
301
+ if slug_is_valid "$PLAN_ID" && [ -d "${PLAN_PREFIX}.planning/${PLAN_ID}" ] && [ ! -L "${PLAN_PREFIX}.planning/${PLAN_ID}" ]; then
299
302
  RESOLVED="${PLAN_PREFIX}.planning/${PLAN_ID}"; SCOPE="scoped"; EXPLICIT=1
300
303
  else
301
304
  if [ "$CONTEXT" = "userprompt" ]; then
@@ -305,7 +308,7 @@ if [ -n "${PLAN_ID:-}" ]; then
305
308
  fi
306
309
  elif [ -f "${PLAN_PREFIX}.planning/.active_plan" ]; then
307
310
  AP=$(tr -d '\r\n[:space:]' < "${PLAN_PREFIX}.planning/.active_plan" 2>/dev/null)
308
- if [ -n "$AP" ] && slug_is_valid "$AP" && [ -d "${PLAN_PREFIX}.planning/${AP}" ]; then
311
+ if [ -n "$AP" ] && slug_is_valid "$AP" && [ -d "${PLAN_PREFIX}.planning/${AP}" ] && [ ! -L "${PLAN_PREFIX}.planning/${AP}" ]; then
309
312
  RESOLVED="${PLAN_PREFIX}.planning/${AP}"; SCOPE="scoped"
310
313
  fi
311
314
  fi
@@ -314,6 +317,7 @@ if [ -z "$RESOLVED" ] && [ -d "${PLAN_PREFIX}.planning" ]; then
314
317
  for d in "${PLAN_PREFIX}".planning/*/; do
315
318
  d="${d%/}"; n="${d##*/}"
316
319
  case "$n" in .*) continue;; esac
320
+ [ -L "$d" ] && continue
317
321
  slug_is_valid "$n" || continue
318
322
  [ -f "$d/task_plan.md" ] || continue
319
323
  m=$(stat -c '%Y' "$d" 2>/dev/null || stat -f '%m' "$d" 2>/dev/null || date -r "$d" +%s 2>/dev/null || echo 0)
@@ -67,7 +67,7 @@ function Resolve-PlanDir {
67
67
 
68
68
  $activePointer = Join-Path $planRoot ".active_plan"
69
69
  if (Test-Path -LiteralPath $activePointer) {
70
- $planId = (Get-Content -LiteralPath $activePointer -Raw).Trim()
70
+ $planId = "$(Get-Content -LiteralPath $activePointer -Raw -ErrorAction SilentlyContinue)".Trim()
71
71
  if ($planId) {
72
72
  $candidate = Join-Path $planRoot $planId
73
73
  if (Test-Path -LiteralPath $candidate -PathType Container) { return $candidate }
@@ -44,7 +44,7 @@ function Resolve-PlanDir {
44
44
 
45
45
  $activePointer = Join-Path $planRoot ".active_plan"
46
46
  if (Test-Path -LiteralPath $activePointer) {
47
- $planId = (Get-Content -LiteralPath $activePointer -Raw).Trim()
47
+ $planId = "$(Get-Content -LiteralPath $activePointer -Raw -ErrorAction SilentlyContinue)".Trim()
48
48
  if ($planId) {
49
49
  $candidate = Join-Path $planRoot $planId
50
50
  if (Test-Path -LiteralPath $candidate -PathType Container) { return $candidate }
@@ -56,7 +56,7 @@ function Resolve-PlanFile {
56
56
 
57
57
  $activePointer = Join-Path $planRoot ".active_plan"
58
58
  if (Test-Path -LiteralPath $activePointer) {
59
- $planId = (Get-Content -LiteralPath $activePointer -Raw).Trim()
59
+ $planId = "$(Get-Content -LiteralPath $activePointer -Raw -ErrorAction SilentlyContinue)".Trim()
60
60
  if ($planId) {
61
61
  $candidate = Join-Path $planRoot $planId
62
62
  $planFile = Join-Path $candidate "task_plan.md"
@@ -77,6 +77,17 @@ function Get-FinalDirectoryPath {
77
77
  return (Resolve-Path -LiteralPath $Path -ErrorAction Stop).Path
78
78
  }
79
79
 
80
+ function Test-FullyQualifiedLocalPath {
81
+ param([string]$Path)
82
+ if (-not $Path -or $Path.StartsWith('\\') -or $Path.StartsWith('//')) {
83
+ return $false
84
+ }
85
+ if ($script:IsWindowsHost) {
86
+ return $Path -match '^[A-Za-z]:[\\/]'
87
+ }
88
+ return [System.IO.Path]::IsPathRooted($Path)
89
+ }
90
+
80
91
  # PWF_PLAN_ROOT: absolute plan-root binding (issue #212), mirroring
81
92
  # resolve-plan-dir.sh. A thread whose cwd is a shared PARENT of the real
82
93
  # project resolves the parent's plan and never sees the nested one;
@@ -90,9 +101,8 @@ function Get-FinalDirectoryPath {
90
101
  # checked against the pinned root. Unset keeps legacy behavior unchanged.
91
102
  if ($env:PWF_PLAN_ROOT) {
92
103
  $pin = $env:PWF_PLAN_ROOT
93
- $isUnc = $pin.StartsWith('\\') -or $pin.StartsWith('//')
94
- $isAbsolute = [System.IO.Path]::IsPathFullyQualified($pin)
95
- if ($isAbsolute -and -not $isUnc -and (Test-Path -LiteralPath $pin -PathType Container)) {
104
+ if ((Test-FullyQualifiedLocalPath $pin) -and
105
+ (Test-Path -LiteralPath $pin -PathType Container)) {
96
106
  $projectRoot = $pin
97
107
  $PlanRoot = Join-Path $pin ".planning"
98
108
  } else {
@@ -129,6 +139,24 @@ function Test-WithinRoot {
129
139
  return $candNorm.StartsWith($rootNorm + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase)
130
140
  }
131
141
 
142
+ # A linked plan directory (symlink or junction) is never selectable: not by
143
+ # PLAN_ID, not by the pointer, not by the newest scan, and it never counts
144
+ # (#270). This is `[ -L ]` of resolve-plan-dir.sh and is_link of the Python
145
+ # twin: LinkType names symlinks and junctions only. The ReparsePoint
146
+ # attribute alone would also match OneDrive Files On-Demand placeholders,
147
+ # which every synced directory and file carries and which are not links to
148
+ # sh. The same predicate guards the pointer file below (#275).
149
+ function Test-LinkedDirectory {
150
+ param($PathOrItem)
151
+ if ($PathOrItem -is [string]) {
152
+ $item = Get-Item -LiteralPath $PathOrItem -Force -ErrorAction SilentlyContinue
153
+ } else {
154
+ $item = $PathOrItem
155
+ }
156
+ if (-not $item) { return $false }
157
+ return ([string]$item.LinkType) -in @('SymbolicLink', 'Junction')
158
+ }
159
+
132
160
  $activeFile = Join-Path $PlanRoot ".active_plan"
133
161
 
134
162
  # A set PLAN_ID is a BINDING, not a hint (issue #237). A selector that names
@@ -148,6 +176,9 @@ if (-not $env:PLAN_ID) {
148
176
  }
149
177
  if (Test-Path -LiteralPath $PlanRoot -PathType Container) {
150
178
  foreach ($entry in (Get-ChildItem -LiteralPath $PlanRoot -Directory -ErrorAction SilentlyContinue)) {
179
+ # A linked plan directory (symlink, junction) is not selectable and
180
+ # never counts, matching `[ -L ]` in resolve-plan-dir.sh (#270).
181
+ if (Test-LinkedDirectory $entry) { continue }
151
182
  if ((Test-ValidSlug $entry.Name) -and
152
183
  (Test-Path -LiteralPath (Join-Path $entry.FullName "task_plan.md") -PathType Leaf)) {
153
184
  $planCount++
@@ -165,7 +196,7 @@ if ($planCount -gt 1) { exit 0 }
165
196
  if ($env:PLAN_ID) {
166
197
  if (Test-ValidSlug $env:PLAN_ID) {
167
198
  $candidate = Join-Path $PlanRoot $env:PLAN_ID
168
- if ((Test-Path $candidate -PathType Container) -and (Test-WithinRoot $candidate)) {
199
+ if ((Test-Path -LiteralPath $candidate -PathType Container) -and -not (Test-LinkedDirectory $candidate) -and (Test-WithinRoot $candidate)) {
169
200
  Write-Output $candidate
170
201
  exit 0
171
202
  }
@@ -175,29 +206,37 @@ if ($env:PLAN_ID) {
175
206
 
176
207
  # Get-Item observes the link object even when its target is missing, unlike
177
208
  # Test-Path which follows the target. An active pointer that is a directory or
178
- # reparse point is an unsafe/ambiguous selector and must terminate resolution;
209
+ # a symlink is an unsafe/ambiguous selector and must terminate resolution;
179
210
  # falling through would silently select and expose the newest unrelated plan.
211
+ # LinkType, not the ReparsePoint attribute: OneDrive Files On-Demand marks
212
+ # every synced file as a reparse point, and such a pointer is a plain file
213
+ # to every other route (#275).
180
214
  $activeItem = Get-Item -LiteralPath $activeFile -Force -ErrorAction SilentlyContinue
181
215
  if ($activeItem) {
182
- if ($activeItem.PSIsContainer -or
183
- (($activeItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)) {
216
+ if ($activeItem.PSIsContainer -or (Test-LinkedDirectory $activeItem)) {
184
217
  exit 0
185
218
  }
186
- $planId = (Get-Content -LiteralPath $activeFile -Raw).Trim()
219
+ # Get-Content -Raw returns $null for a zero-byte pointer; an empty pointer
220
+ # falls through like an invalid one instead of raising inside a caller.
221
+ $planId = "$(Get-Content -LiteralPath $activeFile -Raw -ErrorAction SilentlyContinue)".Trim()
187
222
  if ($planId -and (Test-ValidSlug $planId)) {
188
223
  $candidate = Join-Path $PlanRoot $planId
189
- if ((Test-Path $candidate -PathType Container) -and (Test-WithinRoot $candidate)) {
224
+ if ((Test-Path -LiteralPath $candidate -PathType Container) -and -not (Test-LinkedDirectory $candidate) -and (Test-WithinRoot $candidate)) {
190
225
  Write-Output $candidate
191
226
  exit 0
192
227
  }
193
228
  }
194
229
  }
195
230
 
196
- if (Test-Path $PlanRoot -PathType Container) {
197
- $latest = Get-ChildItem -Path $PlanRoot -Directory |
231
+ # Literal paths throughout: a project path containing [ or ] is a wildcard to
232
+ # Test-Path and Get-ChildItem -Path, and a pattern that matches nothing turned a
233
+ # valid selection into an empty result.
234
+ if (Test-Path -LiteralPath $PlanRoot -PathType Container) {
235
+ $latest = Get-ChildItem -LiteralPath $PlanRoot -Directory |
198
236
  Where-Object { -not $_.Name.StartsWith('.') } |
237
+ Where-Object { -not (Test-LinkedDirectory $_) } |
199
238
  Where-Object { Test-ValidSlug $_.Name } |
200
- Where-Object { Test-Path (Join-Path $_.FullName "task_plan.md") -PathType Leaf } |
239
+ Where-Object { Test-Path -LiteralPath (Join-Path $_.FullName "task_plan.md") -PathType Leaf } |
201
240
  Where-Object { Test-WithinRoot $_.FullName } |
202
241
  Sort-Object LastWriteTime -Descending |
203
242
  Select-Object -First 1
@@ -246,10 +246,16 @@ mtime_of() {
246
246
  printf "0\n"
247
247
  }
248
248
 
249
+ # A linked plan directory (symlink or junction; `-L` sees both under Git
250
+ # Bash) is never selectable: not by PLAN_ID, not by the pointer, not by the
251
+ # newest-mtime scan, and it never counts below (#270). Containment alone let
252
+ # a link that stays inside the root be selected while the counter skipped
253
+ # it, so one real plan plus a newer linked one became an mtime guess again.
249
254
  resolve_from_env() {
250
255
  plan_id="${PLAN_ID:-}"
251
256
  slug_is_valid "${plan_id}" || return 1
252
257
  candidate="${PLAN_ROOT}/${plan_id}"
258
+ [ -L "${candidate}" ] && return 1
253
259
  if [ -d "${candidate}" ] && is_within_root "${candidate}"; then
254
260
  printf "%s\n" "${candidate}"
255
261
  return 0
@@ -268,6 +274,7 @@ resolve_from_active_file() {
268
274
  esac
269
275
  slug_is_valid "${plan_id}" || return 1
270
276
  candidate="${PLAN_ROOT}/${plan_id}"
277
+ [ -L "${candidate}" ] && return 1
271
278
  if [ -d "${candidate}" ] && is_within_root "${candidate}"; then
272
279
  printf "%s\n" "${candidate}"
273
280
  return 0
@@ -288,6 +295,7 @@ resolve_latest_dir() {
288
295
  case "${name}" in
289
296
  .*) continue ;;
290
297
  esac
298
+ [ -L "${clean}" ] && continue
291
299
  slug_is_valid "${name}" || continue
292
300
  [ -f "${clean}/task_plan.md" ] || continue
293
301
  is_within_root "${clean}" || continue
@@ -338,8 +346,9 @@ if [ -z "${PLAN_ID:-}" ]; then
338
346
  PLAN_COUNT=1
339
347
  fi
340
348
  for plan_candidate in "${PLAN_ROOT}"/*/task_plan.md; do
341
- [ -f "$plan_candidate" ] || continue
342
349
  plan_candidate_dir="${plan_candidate%/task_plan.md}"
350
+ [ -L "$plan_candidate_dir" ] && continue
351
+ [ -f "$plan_candidate" ] || continue
343
352
  slug_is_valid "${plan_candidate_dir##*/}" || continue
344
353
  PLAN_COUNT=$((PLAN_COUNT + 1))
345
354
  if [ "$PLAN_COUNT" -gt 1 ]; then PLAN_AMBIGUOUS=1; break; fi
@@ -407,9 +407,6 @@ def get_session_candidates(
407
407
  return 'claude', []
408
408
 
409
409
 
410
- PLANNING_LIKE_SQL = ('%task_plan.md', '%findings.md', '%progress.md')
411
-
412
-
413
410
  def get_opencode_db_path() -> Optional[Path]:
414
411
  """Resolve OpenCode SQLite path. Same on all OS per xdg-basedir."""
415
412
  xdg = os.environ.get('XDG_DATA_HOME')
@@ -471,13 +468,16 @@ def _opencode_state_annotation(state: Any) -> str:
471
468
 
472
469
  def _format_opencode_part(data: Dict[str, Any], session_id: str) -> Optional[Dict[str, Any]]:
473
470
  """Print-ready summary for one OpenCode part row."""
471
+ if not isinstance(data, dict):
472
+ return None
474
473
  ptype = data.get('type')
475
474
  short = safe_session_label(session_id)
476
475
  if ptype == 'tool':
477
- tool = (data.get('tool') or '').lower()
476
+ tool_value = data.get('tool')
477
+ tool = tool_value.lower() if isinstance(tool_value, str) else ''
478
478
  state = data.get('state') or {}
479
479
  input_ = state.get('input') if isinstance(state, dict) else None
480
- input_ = input_ or {}
480
+ input_ = input_ if isinstance(input_, dict) else {}
481
481
  outcome = _opencode_state_annotation(state)
482
482
  if tool in ('write', 'edit'):
483
483
  fp = input_.get('filePath', '')
@@ -489,7 +489,8 @@ def _format_opencode_part(data: Dict[str, Any], session_id: str) -> Optional[Dic
489
489
  return {'session': short, 'summary': f"Tool bash: {cmd}{outcome}"}
490
490
  return {'session': short, 'summary': f"Tool {tool}{outcome}"}
491
491
  if ptype == 'text':
492
- text = (data.get('text') or '')[:300]
492
+ text_value = data.get('text')
493
+ text = text_value[:300] if isinstance(text_value, str) else ''
493
494
  if text.strip():
494
495
  return {'session': short, 'summary': f"text: {text}"}
495
496
  return None
@@ -576,28 +577,43 @@ def opencode_catchup(project_path: str, mode: str = 'no-history') -> None:
576
577
  update_time = None
577
578
  update_idx = -1
578
579
  for idx, (sid, _) in enumerate(previous_sessions):
579
- params = (sid,) + PLANNING_LIKE_SQL
580
580
  cur.execute(
581
581
  """
582
- SELECT time_created FROM part
582
+ SELECT time_created, data FROM part
583
583
  WHERE session_id = ?
584
+ AND json_valid(data)
584
585
  AND json_extract(data, '$.type') = 'tool'
585
586
  AND lower(json_extract(data, '$.tool')) IN ('write', 'edit', 'patch')
586
587
  AND (
587
- json_extract(data, '$.state.input.filePath') LIKE ?
588
- OR json_extract(data, '$.state.input.filePath') LIKE ?
589
- OR json_extract(data, '$.state.input.filePath') LIKE ?
588
+ replace(json_extract(data, '$.state.input.filePath'), char(92), '/')
589
+ IN ('task_plan.md', 'findings.md', 'progress.md')
590
+ OR replace(json_extract(data, '$.state.input.filePath'), char(92), '/')
591
+ GLOB '*/task_plan.md'
592
+ OR replace(json_extract(data, '$.state.input.filePath'), char(92), '/')
593
+ GLOB '*/findings.md'
594
+ OR replace(json_extract(data, '$.state.input.filePath'), char(92), '/')
595
+ GLOB '*/progress.md'
590
596
  )
591
- ORDER BY time_created DESC
592
- LIMIT 1
597
+ ORDER BY time_created DESC, id DESC
593
598
  """,
594
- params,
599
+ (sid,),
595
600
  )
596
- row = cur.fetchone()
597
- if row:
598
- update_sid = sid
599
- update_time = row[0]
600
- update_idx = idx
601
+ # Iterate lazily: write parts carry whole file bodies, and fetchall
602
+ # would materialize every planning write of the session before the
603
+ # first validated row ends the loop.
604
+ for candidate_time, data_str in cur:
605
+ data = json_loads(data_str)
606
+ if not isinstance(data, dict):
607
+ continue
608
+ state = data.get('state')
609
+ input_ = state.get('input') if isinstance(state, dict) else None
610
+ file_path = input_.get('filePath') if isinstance(input_, dict) else None
611
+ if planning_file_from_path(file_path):
612
+ update_sid = sid
613
+ update_time = candidate_time
614
+ update_idx = idx
615
+ break
616
+ if update_sid:
601
617
  break
602
618
 
603
619
  if not update_sid:
@@ -685,12 +701,17 @@ def parse_session_messages(session_file: Path) -> List[Dict[str, Any]]:
685
701
 
686
702
 
687
703
  def planning_file_from_path(path_value: Any) -> Optional[str]:
704
+ """Return a planning filename only when it is the path's exact basename.
705
+
706
+ A suffix check treats lookalikes such as ``draft_task_plan.md`` as real
707
+ planning updates and can anchor catchup at unrelated transcript content.
708
+ Normalize separators so the same boundary rule works for Unix and Windows
709
+ session records.
710
+ """
688
711
  if not isinstance(path_value, str):
689
712
  return None
690
- for pf in PLANNING_FILES:
691
- if path_value.endswith(pf):
692
- return pf
693
- return None
713
+ basename = path_value.replace(chr(92), '/').rsplit('/', 1)[-1]
714
+ return basename if basename in PLANNING_FILES else None
694
715
 
695
716
 
696
717
  def planning_file_from_paths(paths: Iterable[Any]) -> Optional[str]:
@@ -5,12 +5,16 @@
5
5
  # .\set-active-plan.ps1 - print the current active plan (if any)
6
6
  # .\set-active-plan.ps1 -List - list available named plans and phase counts
7
7
  # .\set-active-plan.ps1 --list - equivalent to -List and -l
8
+ # .\set-active-plan.ps1 -VerifyRoot - check the planning root and pointer only
9
+ # .\set-active-plan.ps1 --verify-root - equivalent to -VerifyRoot
8
10
 
9
11
  param(
10
12
  [Parameter(Position = 0)]
11
13
  [string]$PlanId = "",
12
14
  [Alias('l', '-list')]
13
15
  [switch]$List,
16
+ [Alias('verify-root')]
17
+ [switch]$VerifyRoot,
14
18
  [Alias('h', '-help')]
15
19
  [switch]$Help
16
20
  )
@@ -117,9 +121,13 @@ function Test-SafeActiveFile {
117
121
  param([switch]$AllowLink)
118
122
  $item = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
119
123
  if (-not $item) { return $false }
120
- # Reading may follow a verified in-project link; writing must not.
124
+ # Reading may follow a verified in-project link; writing must not. A link
125
+ # is a symlink or junction by LinkType, never the bare ReparsePoint
126
+ # attribute: OneDrive Files On-Demand marks every synced file as a
127
+ # reparse point, and the pointer must stay writable there (#275).
128
+ $linked = ([string]$item.LinkType) -in @('SymbolicLink', 'Junction')
121
129
  return -not $item.PSIsContainer -and
122
- ($AllowLink -or (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0)) -and
130
+ ($AllowLink -or -not $linked) -and
123
131
  (Test-WithinRoot $ActiveFile)
124
132
  }
125
133
 
@@ -200,6 +208,8 @@ function Show-PlanList {
200
208
  Write-Output "[active] marks the shared .active_plan pointer; listing does not bind this session."
201
209
  $found = $false
202
210
  foreach ($plan in (Get-ChildItem -LiteralPath $PlanRoot -Directory -ErrorAction SilentlyContinue | Sort-Object Name)) {
211
+ # A linked plan directory is never a plan (#270): no resolver selects it.
212
+ if (([string]$plan.LinkType) -in @('SymbolicLink', 'Junction')) { continue }
203
213
  if (-not (Test-ValidSlug $plan.Name) -or -not (Test-WithinRoot $plan.FullName)) { continue }
204
214
  $planFile = Join-Path $plan.FullName "task_plan.md"
205
215
  if (-not (Test-Path -LiteralPath $planFile -PathType Leaf) -or -not (Test-WithinRoot $planFile)) { continue }
@@ -213,7 +223,27 @@ function Show-PlanList {
213
223
  }
214
224
 
215
225
  if ($Help -or $PlanId -eq "--help" -or $PlanId -eq "-h") {
216
- Write-Output "Usage: set-active-plan.ps1 [-List|-l|--list|PLAN_ID]"
226
+ Write-Output "Usage: set-active-plan.ps1 [-List|-l|--list|-VerifyRoot|PLAN_ID]"
227
+ exit 0
228
+ }
229
+
230
+ # Constant-time check for callers that are about to create a plan: the
231
+ # planning root, when present, must be inside the project, and an existing
232
+ # pointer must be replaceable. Nothing is read, listed, or written.
233
+ if ($VerifyRoot) {
234
+ if ($List -or $PlanId) {
235
+ Write-Error "Error: verify the planning root in a separate call."
236
+ exit 1
237
+ }
238
+ if ((Test-Path -LiteralPath $PlanRoot -PathType Container) -and -not (Test-WithinRoot $PlanRoot)) {
239
+ Write-Error "Error: planning directory is outside the project or cannot be verified."
240
+ exit 1
241
+ }
242
+ $existingPointer = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
243
+ if ($existingPointer -and -not (Test-SafeActiveFile)) {
244
+ Write-Error "Error: the active plan pointer must be a regular file within the project."
245
+ exit 1
246
+ }
217
247
  exit 0
218
248
  }
219
249
 
@@ -257,6 +287,11 @@ if (-not (Test-Path -LiteralPath $PlanDir -PathType Container)) {
257
287
  Write-Error "Run: init-session.sh `"$PlanId`" to create it, or check .planning\ for available plans."
258
288
  exit 1
259
289
  }
290
+ $planDirItem = Get-Item -LiteralPath $PlanDir -Force -ErrorAction SilentlyContinue
291
+ if ($planDirItem -and (([string]$planDirItem.LinkType) -in @('SymbolicLink', 'Junction'))) {
292
+ Write-Error "Error: plan directory is a symlink or junction and no route selects it: $PlanDir"
293
+ exit 1
294
+ }
260
295
  if (-not (Test-WithinRoot $PlanRoot) -or -not (Test-WithinRoot $PlanDir)) {
261
296
  Write-Error "Error: plan directory must remain within the project."
262
297
  exit 1