planning-with-files 3.19.0 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "planning-with-files",
3
- "version": "3.19.0",
3
+ "version": "3.20.5",
4
4
  "description": "Persistent project planning with selected context injection. Automatic recovery uses project files only; explicit catchup modes read same-project local session records for aggregate counts or bounded replay. The host-aware gate never runs Markdown-declared commands. No network upload path. Ships the skill plus a Pi Coding Agent extension.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -116,7 +116,13 @@ public static class PwfAttestationNative {
116
116
  handle, FileAttributeTagInfo, out tag,
117
117
  (uint)Marshal.SizeOf(typeof(FILE_ATTRIBUTE_TAG_INFO))))
118
118
  throw new Win32Exception(Marshal.GetLastWin32Error());
119
- if ((tag.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0)
119
+ // Refuse only name-surrogate reparse points (symlinks, junctions, and any
120
+ // unknown tag with bit 29 set): those are what path parsing follows. A
121
+ // OneDrive Files On-Demand placeholder (tag 0x9000601A) is a regular file
122
+ // that every other route reads; refusing it broke attestation, --show
123
+ // and --clear in every project under OneDrive (#275).
124
+ if ((tag.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0 &&
125
+ (tag.ReparseTag & 0x20000000) != 0)
120
126
  throw new IOException("Refusing a reparse-point file.");
121
127
  if ((tag.FileAttributes & (uint)FileAttributes.Directory) != 0)
122
128
  throw new IOException("Refusing a directory where a regular file is required.");
@@ -218,8 +224,10 @@ if ($script:IsWindowsHost) {
218
224
  $securityRootPath = (Get-Location).Path
219
225
  if ($env:PWF_PLAN_ROOT) {
220
226
  $pin = $env:PWF_PLAN_ROOT
227
+ # Windows PowerShell 5.1 has no IsPathFullyQualified; a drive-qualified
228
+ # local path is the only accepted shape, as in resolve-plan-dir.ps1.
221
229
  $isUnc = $pin.StartsWith('\\') -or $pin.StartsWith('//')
222
- if (-not [IO.Path]::IsPathFullyQualified($pin) -or $isUnc) {
230
+ if ($isUnc -or ($pin -notmatch '^[A-Za-z]:[\\/]')) {
223
231
  throw "[plan-attest] PWF_PLAN_ROOT must be an absolute local path."
224
232
  }
225
233
  $securityRootPath = $pin
@@ -80,6 +80,26 @@ function Get-InheritedMode([string]$CurrentMode) {
80
80
  return $CurrentMode
81
81
  }
82
82
 
83
+ function Format-AttestationFailureReason {
84
+ param([object[]]$Output, [string]$Fallback)
85
+
86
+ $Reason = @(
87
+ $Output |
88
+ ForEach-Object {
89
+ if ($null -ne $_) { ($_.ToString()).Trim() }
90
+ } |
91
+ Where-Object { $_ }
92
+ ) -join " "
93
+ $Reason = ($Reason -replace '\s+', ' ').Trim()
94
+ if ([string]::IsNullOrWhiteSpace($Reason)) {
95
+ return $Fallback
96
+ }
97
+ if ($Reason.Length -gt 300) {
98
+ return ($Reason.Substring(0, 297) + "...")
99
+ }
100
+ return $Reason
101
+ }
102
+
83
103
  # Validate template
84
104
  if ($Template -ne "default" -and $Template -ne "analytics") {
85
105
  Write-Host "Unknown template: $Template (available: default, analytics). Using default."
@@ -136,20 +156,7 @@ if ($UsePlanDir) {
136
156
  $Counter++
137
157
  }
138
158
  $TargetDir = Join-Path $PlanningRoot $PlanId
139
- New-Item -ItemType Directory -Path $TargetDir -Force | Out-Null
140
- # Reuse the selector's contained, atomic pointer replacement. Set-Content
141
- # would follow a reparse point and truncate a hardlinked pointer in place,
142
- # overwriting whichever file shares that inode.
143
- $global:LASTEXITCODE = 0
144
- try {
145
- & $PlanSelector $PlanId *> $null
146
- } catch {
147
- $global:LASTEXITCODE = 1
148
- }
149
- if ($LASTEXITCODE -ne 0) {
150
- Write-Error "Error: could not safely update the active plan pointer at $(Join-Path $PlanningRoot '.active_plan')."
151
- exit 1
152
- }
159
+ New-Item -ItemType Directory -Path $TargetDir -Force -ErrorAction Stop | Out-Null
153
160
  $Mode = Get-InheritedMode $Mode
154
161
  } else {
155
162
  $TargetDir = (Get-Location).Path
@@ -173,11 +180,12 @@ if ($UsePlanDir) {
173
180
  Write-Host "PLAN_ID=$PlanId"
174
181
  }
175
182
 
183
+ try {
176
184
  # Create task_plan.md if it doesn't exist
177
185
  if (-not (Test-Path -LiteralPath $TaskPlanPath)) {
178
186
  $AnalyticsPlan = Join-Path $TemplateDir "analytics_task_plan.md"
179
187
  if ($Template -eq "analytics" -and (Test-Path $AnalyticsPlan)) {
180
- Copy-Item -LiteralPath $AnalyticsPlan -Destination $TaskPlanPath
188
+ Copy-Item -LiteralPath $AnalyticsPlan -Destination $TaskPlanPath -ErrorAction Stop
181
189
  } else {
182
190
  @"
183
191
  # Task Plan: [Brief Description]
@@ -226,7 +234,7 @@ Phase 1
226
234
  ## Errors Encountered
227
235
  | Error | Resolution |
228
236
  |-------|------------|
229
- "@ | Out-File -LiteralPath $TaskPlanPath -Encoding UTF8
237
+ "@ | Out-File -LiteralPath $TaskPlanPath -Encoding UTF8 -ErrorAction Stop
230
238
  }
231
239
  Write-Host "Created $TaskPlanDisplay"
232
240
  } else {
@@ -237,7 +245,7 @@ Phase 1
237
245
  if (-not (Test-Path -LiteralPath $FindingsPath)) {
238
246
  $AnalyticsFindings = Join-Path $TemplateDir "analytics_findings.md"
239
247
  if ($Template -eq "analytics" -and (Test-Path $AnalyticsFindings)) {
240
- Copy-Item -LiteralPath $AnalyticsFindings -Destination $FindingsPath
248
+ Copy-Item -LiteralPath $AnalyticsFindings -Destination $FindingsPath -ErrorAction Stop
241
249
  } else {
242
250
  @"
243
251
  # Findings & Decisions
@@ -258,7 +266,7 @@ if (-not (Test-Path -LiteralPath $FindingsPath)) {
258
266
 
259
267
  ## Resources
260
268
  -
261
- "@ | Out-File -LiteralPath $FindingsPath -Encoding UTF8
269
+ "@ | Out-File -LiteralPath $FindingsPath -Encoding UTF8 -ErrorAction Stop
262
270
  }
263
271
  Write-Host "Created $FindingsDisplay"
264
272
  } else {
@@ -287,7 +295,7 @@ if (-not (Test-Path -LiteralPath $ProgressPath)) {
287
295
  ### Errors
288
296
  | Error | Resolution |
289
297
  |-------|------------|
290
- "@ | Out-File -LiteralPath $ProgressPath -Encoding UTF8
298
+ "@ | Out-File -LiteralPath $ProgressPath -Encoding UTF8 -ErrorAction Stop
291
299
  } else {
292
300
  @"
293
301
  # Progress Log
@@ -308,12 +316,32 @@ if (-not (Test-Path -LiteralPath $ProgressPath)) {
308
316
  ### Errors
309
317
  | Error | Resolution |
310
318
  |-------|------------|
311
- "@ | Out-File -LiteralPath $ProgressPath -Encoding UTF8
319
+ "@ | Out-File -LiteralPath $ProgressPath -Encoding UTF8 -ErrorAction Stop
312
320
  }
313
321
  Write-Host "Created $ProgressDisplay"
314
322
  } else {
315
323
  Write-Host "$ProgressDisplay already exists, skipping"
316
324
  }
325
+ } catch {
326
+ Write-Error "Error: could not initialize planning files in '$TargetDir': $($_.Exception.Message)"
327
+ exit 1
328
+ }
329
+
330
+ if ($UsePlanDir) {
331
+ # Activate the named plan only after all three planning files are ready.
332
+ # This matches init-session.sh and prevents a failed initialization from
333
+ # leaving .active_plan pointed at a partial plan directory.
334
+ $global:LASTEXITCODE = 0
335
+ try {
336
+ & $PlanSelector $PlanId *> $null
337
+ } catch {
338
+ $global:LASTEXITCODE = 1
339
+ }
340
+ if ($LASTEXITCODE -ne 0) {
341
+ Write-Error "Error: could not safely update the active plan pointer at $(Join-Path $PlanningRoot '.active_plan')."
342
+ exit 1
343
+ }
344
+ }
317
345
 
318
346
  Write-Host ""
319
347
  Write-Host "Planning files initialized!"
@@ -350,42 +378,94 @@ if ($Mode -ne "") {
350
378
  # (c) auto-attest (attestation default-on in v3 modes, security strand rec 1).
351
379
  # attest-plan.ps1 intentionally refuses non-Windows hosts because its secure
352
380
  # no-follow implementation uses Win32 handles. On Unix, use the POSIX
353
- # attester instead. Bind slug mode to the plan we just created so an
354
- # inherited PLAN_ID cannot redirect attestation to another plan.
381
+ # attester instead. Slug mode binds PWF_PLAN_ROOT and PLAN_ID to the plan
382
+ # we just created so an inherited pin or slug cannot redirect attestation
383
+ # to another project or plan (#261, #237). Root mode clears both instead:
384
+ # the attester only falls back to the legacy ./task_plan.md when no
385
+ # selector is set, and a bound pin would make it refuse the root plan.
386
+ $AttestationSucceeded = $false
387
+ $AttestationCommand = "attest-plan"
388
+ $AttestationReason = "task_plan.md was not available for attestation"
355
389
  $PlanFilePwf = Join-Path $PlanDirPwf "task_plan.md"
356
- if (Test-Path -LiteralPath $PlanFilePwf) {
390
+ if (Test-Path -LiteralPath $PlanFilePwf -PathType Leaf) {
357
391
  $HadPlanId = Test-Path Env:PLAN_ID
358
392
  $PreviousPlanId = $env:PLAN_ID
393
+ $HadPlanRoot = Test-Path Env:PWF_PLAN_ROOT
394
+ $PreviousPlanRoot = $env:PWF_PLAN_ROOT
359
395
  try {
360
396
  if ($UsePlanDir) {
397
+ $env:PWF_PLAN_ROOT = (Get-Location).Path
361
398
  $env:PLAN_ID = $PlanId
362
399
  } else {
400
+ Remove-Item Env:PWF_PLAN_ROOT -ErrorAction SilentlyContinue
363
401
  Remove-Item Env:PLAN_ID -ErrorAction SilentlyContinue
364
402
  }
365
403
 
404
+ # A called script can return without changing $LASTEXITCODE, so
405
+ # clear the inherited value before every attempt. Both a non-zero
406
+ # status and a terminating exception mean the plan is not attested.
407
+ $global:LASTEXITCODE = 0
408
+ $AttestOutput = @()
366
409
  $IsWindowsHost = [Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT
367
410
  if ($IsWindowsHost) {
368
- $AttestPs1 = Join-Path $ScriptDir "attest-plan.ps1"
369
- if (Test-Path -LiteralPath $AttestPs1) {
370
- & $AttestPs1 *> $null
411
+ $AttestationCommand = "attest-plan.ps1"
412
+ $AttestPs1 = Join-Path $ScriptDir $AttestationCommand
413
+ if (Test-Path -LiteralPath $AttestPs1 -PathType Leaf) {
414
+ $AttestOutput = @(& $AttestPs1 2>&1)
415
+ $AttestExitCode = $LASTEXITCODE
416
+ if ($AttestExitCode -eq 0) {
417
+ $AttestationSucceeded = $true
418
+ $AttestationReason = ""
419
+ } else {
420
+ $AttestationReason = Format-AttestationFailureReason `
421
+ -Output $AttestOutput `
422
+ -Fallback "$AttestationCommand exited with code $AttestExitCode"
423
+ }
424
+ } else {
425
+ $AttestationReason = "$AttestationCommand was not found beside init-session.ps1"
371
426
  }
372
427
  } else {
373
- $AttestSh = Join-Path $ScriptDir "attest-plan.sh"
428
+ $AttestationCommand = "attest-plan.sh"
429
+ $AttestSh = Join-Path $ScriptDir $AttestationCommand
374
430
  $Sh = Get-Command sh -ErrorAction SilentlyContinue
375
- if ($Sh -and (Test-Path -LiteralPath $AttestSh)) {
376
- & $Sh.Path $AttestSh *> $null
431
+ if ($Sh -and (Test-Path -LiteralPath $AttestSh -PathType Leaf)) {
432
+ $AttestOutput = @(& $Sh.Path $AttestSh 2>&1)
433
+ $AttestExitCode = $LASTEXITCODE
434
+ if ($AttestExitCode -eq 0) {
435
+ $AttestationSucceeded = $true
436
+ $AttestationReason = ""
437
+ } else {
438
+ $AttestationReason = Format-AttestationFailureReason `
439
+ -Output $AttestOutput `
440
+ -Fallback "$AttestationCommand exited with code $AttestExitCode"
441
+ }
442
+ } elseif (-not $Sh) {
443
+ $AttestationReason = "sh was not found; $AttestationCommand could not run"
444
+ } else {
445
+ $AttestationReason = "$AttestationCommand was not found beside init-session.ps1"
377
446
  }
378
447
  }
379
448
  } catch {
380
- # attestation failure must not abort init; the mode marker still stands.
449
+ $AttestationReason = Format-AttestationFailureReason `
450
+ -Output @($_) `
451
+ -Fallback "$AttestationCommand failed"
381
452
  } finally {
382
453
  if ($HadPlanId) {
383
454
  $env:PLAN_ID = $PreviousPlanId
384
455
  } else {
385
456
  Remove-Item Env:PLAN_ID -ErrorAction SilentlyContinue
386
457
  }
458
+ if ($HadPlanRoot) {
459
+ $env:PWF_PLAN_ROOT = $PreviousPlanRoot
460
+ } else {
461
+ Remove-Item Env:PWF_PLAN_ROOT -ErrorAction SilentlyContinue
462
+ }
387
463
  }
388
464
  }
389
465
 
390
- Write-Host "Mode: $MarkerText (attested, gate counter reset)"
466
+ if ($AttestationSucceeded) {
467
+ Write-Host "Mode: $MarkerText (attested, gate counter reset)"
468
+ } else {
469
+ Write-Host "Mode: $MarkerText (NOT attested: $AttestationReason; run $AttestationCommand before the first hook fire)"
470
+ }
391
471
  }
@@ -103,6 +103,7 @@ slugify() {
103
103
  # Lowercase, non-alphanumerics → '-', collapse repeats, trim leading/trailing '-'
104
104
  printf '%s' "$1" \
105
105
  | tr '[:upper:]' '[:lower:]' \
106
+ | tr '\r\n' '--' \
106
107
  | sed -e 's/[^a-z0-9]/-/g' -e 's/-\{2,\}/-/g' -e 's/^-//' -e 's/-$//' \
107
108
  | cut -c1-40
108
109
  }
@@ -185,6 +186,10 @@ apply_v3_mode() {
185
186
  _mode_plan="$2"
186
187
  [ -z "$MODE" ] && return 0
187
188
 
189
+ ATTESTATION_OK=0
190
+ ATTESTATION_COMMAND="attest-plan.sh"
191
+ ATTESTATION_REASON="task_plan.md was not available for attestation"
192
+
188
193
  # (a) reset the gate block counter and drop any stale gate ledger so a prior
189
194
  # run's high block count cannot let the next run stop instantly.
190
195
  printf '0\n' > "${_mode_dir}/.stop_blocks"
@@ -202,12 +207,61 @@ apply_v3_mode() {
202
207
 
203
208
  # (c) auto-attest the plan (attestation default-on in v3 modes, security
204
209
  # strand rec 1). attest-plan.sh resolves the same way init-session just
205
- # pinned things: in slug mode PLAN_ID points at this plan dir; in legacy
206
- # mode it is empty and the script falls back to ./task_plan.md at root.
207
- # Run from the project root (CWD here) so both resolutions land.
208
- _attest="${SCRIPT_DIR}/attest-plan.sh"
209
- if [ -f "${_attest}" ] && [ -f "${_mode_plan}" ]; then
210
- 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}"
211
265
  fi
212
266
  }
213
267
 
@@ -422,7 +476,7 @@ if [ "$SLUG_MODE" -eq 1 ]; then
422
476
  echo "Pin this terminal to the plan for parallel sessions:"
423
477
  echo " export PLAN_ID=$PLAN_ID"
424
478
  if [ -n "$MODE" ]; then
425
- echo "Mode: $(cat "${PLAN_DIR}/.mode") (attested, gate counter reset)"
479
+ print_v3_mode_status "${PLAN_DIR}"
426
480
  fi
427
481
  else
428
482
  PROJECT_NAME="${PROJECT_NAME:-project}"
@@ -433,6 +487,6 @@ else
433
487
  echo "Planning files initialized!"
434
488
  echo "Files: task_plan.md, findings.md, progress.md"
435
489
  if [ -n "$MODE" ]; then
436
- echo "Mode: $(cat "$(pwd)/.mode") (attested, gate counter reset)"
490
+ print_v3_mode_status "$(pwd)"
437
491
  fi
438
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
@@ -468,13 +468,16 @@ def _opencode_state_annotation(state: Any) -> str:
468
468
 
469
469
  def _format_opencode_part(data: Dict[str, Any], session_id: str) -> Optional[Dict[str, Any]]:
470
470
  """Print-ready summary for one OpenCode part row."""
471
+ if not isinstance(data, dict):
472
+ return None
471
473
  ptype = data.get('type')
472
474
  short = safe_session_label(session_id)
473
475
  if ptype == 'tool':
474
- tool = (data.get('tool') or '').lower()
476
+ tool_value = data.get('tool')
477
+ tool = tool_value.lower() if isinstance(tool_value, str) else ''
475
478
  state = data.get('state') or {}
476
479
  input_ = state.get('input') if isinstance(state, dict) else None
477
- input_ = input_ or {}
480
+ input_ = input_ if isinstance(input_, dict) else {}
478
481
  outcome = _opencode_state_annotation(state)
479
482
  if tool in ('write', 'edit'):
480
483
  fp = input_.get('filePath', '')
@@ -486,7 +489,8 @@ def _format_opencode_part(data: Dict[str, Any], session_id: str) -> Optional[Dic
486
489
  return {'session': short, 'summary': f"Tool bash: {cmd}{outcome}"}
487
490
  return {'session': short, 'summary': f"Tool {tool}{outcome}"}
488
491
  if ptype == 'text':
489
- text = (data.get('text') or '')[:300]
492
+ text_value = data.get('text')
493
+ text = text_value[:300] if isinstance(text_value, str) else ''
490
494
  if text.strip():
491
495
  return {'session': short, 'summary': f"text: {text}"}
492
496
  return None
@@ -577,6 +581,7 @@ def opencode_catchup(project_path: str, mode: str = 'no-history') -> None:
577
581
  """
578
582
  SELECT time_created, data FROM part
579
583
  WHERE session_id = ?
584
+ AND json_valid(data)
580
585
  AND json_extract(data, '$.type') = 'tool'
581
586
  AND lower(json_extract(data, '$.tool')) IN ('write', 'edit', 'patch')
582
587
  AND (
@@ -121,9 +121,13 @@ function Test-SafeActiveFile {
121
121
  param([switch]$AllowLink)
122
122
  $item = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
123
123
  if (-not $item) { return $false }
124
- # 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')
125
129
  return -not $item.PSIsContainer -and
126
- ($AllowLink -or (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0)) -and
130
+ ($AllowLink -or -not $linked) -and
127
131
  (Test-WithinRoot $ActiveFile)
128
132
  }
129
133
 
@@ -204,6 +208,8 @@ function Show-PlanList {
204
208
  Write-Output "[active] marks the shared .active_plan pointer; listing does not bind this session."
205
209
  $found = $false
206
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 }
207
213
  if (-not (Test-ValidSlug $plan.Name) -or -not (Test-WithinRoot $plan.FullName)) { continue }
208
214
  $planFile = Join-Path $plan.FullName "task_plan.md"
209
215
  if (-not (Test-Path -LiteralPath $planFile -PathType Leaf) -or -not (Test-WithinRoot $planFile)) { continue }
@@ -281,6 +287,11 @@ if (-not (Test-Path -LiteralPath $PlanDir -PathType Container)) {
281
287
  Write-Error "Run: init-session.sh `"$PlanId`" to create it, or check .planning\ for available plans."
282
288
  exit 1
283
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
+ }
284
295
  if (-not (Test-WithinRoot $PlanRoot) -or -not (Test-WithinRoot $PlanDir)) {
285
296
  Write-Error "Error: plan directory must remain within the project."
286
297
  exit 1
@@ -272,6 +272,9 @@ list_plans() {
272
272
  printf '%s\n' 'Available plans:'
273
273
  for _dir in "${PLAN_ROOT}"/*; do
274
274
  [ -d "${_dir}" ] || continue
275
+ # A linked plan directory is never a plan (#270): no resolver selects
276
+ # it, so listing it would advertise a PLAN_ID every route refuses.
277
+ [ -L "${_dir}" ] && continue
275
278
  _id="${_dir##*/}"
276
279
  slug_is_valid "${_id}" || continue
277
280
  is_within_root "${_dir}" || continue
@@ -312,7 +315,7 @@ if [ "${1:-}" = '' ]; then
312
315
  exit 1
313
316
  fi
314
317
  plan_id="$(current_active)"
315
- if [ -n "${plan_id}" ] && [ -d "${PLAN_ROOT}/${plan_id}" ] && is_within_root "${PLAN_ROOT}/${plan_id}"; then
318
+ if [ -n "${plan_id}" ] && [ -d "${PLAN_ROOT}/${plan_id}" ] && [ ! -L "${PLAN_ROOT}/${plan_id}" ] && is_within_root "${PLAN_ROOT}/${plan_id}"; then
316
319
  printf '%s\n' "Active plan: ${plan_id}" "Path: ${PLAN_ROOT}/${plan_id}"
317
320
  elif [ -n "${plan_id}" ]; then
318
321
  printf '%s\n' "Active plan pointer: ${plan_id} (directory not found or outside project - stale pointer)"
@@ -333,6 +336,10 @@ if [ ! -d "${PLAN_DIR}" ]; then
333
336
  "Run: init-session.sh \"${PLAN_ID}\" to create it, or use --list to see available plans." >&2
334
337
  exit 1
335
338
  fi
339
+ if [ -L "${PLAN_DIR}" ]; then
340
+ printf '%s\n' "Error: plan directory is a symlink or junction and no route selects it: ${PLAN_DIR}" >&2
341
+ exit 1
342
+ fi
336
343
  if ! is_within_root "${PLAN_ROOT}" || ! is_within_root "${PLAN_DIR}"; then
337
344
  printf '%s\n' 'Error: plan directory is outside the project or cannot be verified.' >&2
338
345
  exit 1
@@ -1,81 +1,87 @@
1
- # Task Plan: [Analytics Project Description]
2
-
3
- Use this file as the durable roadmap for a data analytics or exploration session. Keep phase status current as the analysis advances.
4
-
5
- ## Goal
6
-
7
- State the analytical question or intended deliverable in one clear sentence.
8
-
9
- [One sentence describing the analytical objective]
10
-
11
- ## Current Phase
12
-
13
- Name the phase currently being worked on.
14
-
15
- Phase 1
16
-
17
- ## Phases
18
-
19
- Use only `pending`, `in_progress`, or `complete` for each status.
20
-
21
- ### Phase 1: Data Discovery
22
-
23
- - [ ] Identify and connect to data sources
24
- - [ ] Document schemas and field descriptions in findings.md
25
- - [ ] Assess data quality (nulls, duplicates, outliers, date ranges)
26
- - [ ] Estimate dataset size and query performance
27
- - **Status:** in_progress
28
-
29
- ### Phase 2: Exploratory Analysis
30
-
31
- - [ ] Compute summary statistics for key variables
32
- - [ ] Visualize distributions and relationships
33
- - [ ] Identify outliers and anomalies
34
- - [ ] Document initial patterns in findings.md
35
- - **Status:** pending
36
-
37
- ### Phase 3: Hypothesis Testing
38
-
39
- - [ ] Formalize hypotheses from exploratory phase
40
- - [ ] Select appropriate statistical tests
41
- - [ ] Run tests and record results in findings.md
42
- - [ ] Validate findings against holdout data or alternative methods
43
- - **Status:** pending
44
-
45
- ### Phase 4: Synthesis & Reporting
46
-
47
- - [ ] Summarize key findings with supporting evidence
48
- - [ ] Create final visualizations
49
- - [ ] Document conclusions and recommendations
50
- - [ ] Note limitations and areas for further investigation
51
- - **Status:** pending
52
-
53
- ## Hypotheses
54
-
55
- Record the questions under investigation as testable hypotheses.
56
-
57
- 1. [Hypothesis to test]
58
- 2. [Hypothesis to test]
59
-
60
- ## Decisions Made
61
-
62
- Record analytical choices, including tests, filters, exclusions, and their rationale.
63
-
64
- | Decision | Rationale |
65
- |----------|-----------|
66
- | | |
67
-
68
- ## Errors Encountered
69
-
70
- Record each distinct error, the attempt number, and the resolution. Change the approach before retrying a failed action.
71
-
72
- | Error | Attempt | Resolution |
73
- |-------|---------|------------|
74
- | | 1 | |
75
-
76
- ## Notes
77
-
78
- - Update phase status as work progresses: `pending` to `in_progress` to `complete`.
79
- - Re-read the goal and current phase before major analytical decisions.
80
- - Log errors promptly so failed approaches are not repeated.
81
- - Record query results and visual evidence in findings.md.
1
+ # Task Plan: [Analytics Project Description]
2
+
3
+ Use this file as the durable roadmap for a data analytics or exploration session. Keep phase status current as the analysis advances.
4
+
5
+ ## Goal
6
+
7
+ State the analytical question or intended deliverable in one clear sentence.
8
+
9
+ [One sentence describing the analytical objective]
10
+
11
+ ## Next Step
12
+
13
+ Record the single analytical action that should happen next. Update it whenever the active phase or immediate action changes.
14
+
15
+ [The single next analytical action. Update whenever phase status changes.]
16
+
17
+ ## Current Phase
18
+
19
+ Name the phase currently being worked on.
20
+
21
+ Phase 1
22
+
23
+ ## Phases
24
+
25
+ Use only `pending`, `in_progress`, or `complete` for each status.
26
+
27
+ ### Phase 1: Data Discovery
28
+
29
+ - [ ] Identify and connect to data sources
30
+ - [ ] Document schemas and field descriptions in findings.md
31
+ - [ ] Assess data quality (nulls, duplicates, outliers, date ranges)
32
+ - [ ] Estimate dataset size and query performance
33
+ - **Status:** in_progress
34
+
35
+ ### Phase 2: Exploratory Analysis
36
+
37
+ - [ ] Compute summary statistics for key variables
38
+ - [ ] Visualize distributions and relationships
39
+ - [ ] Identify outliers and anomalies
40
+ - [ ] Document initial patterns in findings.md
41
+ - **Status:** pending
42
+
43
+ ### Phase 3: Hypothesis Testing
44
+
45
+ - [ ] Formalize hypotheses from exploratory phase
46
+ - [ ] Select appropriate statistical tests
47
+ - [ ] Run tests and record results in findings.md
48
+ - [ ] Validate findings against holdout data or alternative methods
49
+ - **Status:** pending
50
+
51
+ ### Phase 4: Synthesis & Reporting
52
+
53
+ - [ ] Summarize key findings with supporting evidence
54
+ - [ ] Create final visualizations
55
+ - [ ] Document conclusions and recommendations
56
+ - [ ] Note limitations and areas for further investigation
57
+ - **Status:** pending
58
+
59
+ ## Hypotheses
60
+
61
+ Record the questions under investigation as testable hypotheses.
62
+
63
+ 1. [Hypothesis to test]
64
+ 2. [Hypothesis to test]
65
+
66
+ ## Decisions Made
67
+
68
+ Record analytical choices, including tests, filters, exclusions, and their rationale.
69
+
70
+ | Decision | Rationale |
71
+ |----------|-----------|
72
+ | | |
73
+
74
+ ## Errors Encountered
75
+
76
+ Record each distinct error, the attempt number, and the resolution. Change the approach before retrying a failed action.
77
+
78
+ | Error | Attempt | Resolution |
79
+ |-------|---------|------------|
80
+ | | 1 | |
81
+
82
+ ## Notes
83
+
84
+ - Update phase status as work progresses: `pending` to `in_progress` to `complete`.
85
+ - Re-read the goal, next step, and current phase before major analytical decisions.
86
+ - Log errors promptly so failed approaches are not repeated.
87
+ - Record query results and visual evidence in findings.md.