planning-with-files 3.17.1 → 3.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "planning-with-files",
3
- "version": "3.17.1",
3
+ "version": "3.19.0",
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",
@@ -10,7 +10,8 @@
10
10
  # 4. the block counter (<plan-dir>/.stop_blocks) is below cap (PWF_GATE_CAP, default 20)
11
11
  # 5. the ledger advanced since the last block (stall -> allow stop)
12
12
  # When all hold, emits a single-line block-decision JSON on stdout and exits 0.
13
- # Otherwise advisory output and exit 0. Without -Gate, byte-equivalent to v2.43.
13
+ # Otherwise reports incomplete plans and exits 0; completed plans stay silent
14
+ # with -Gate, including legacy plans without .mode. Explicit reports are unchanged.
14
15
  #
15
16
  # Stdin: read only when input is redirected ([Console]::IsInputRedirected), so an
16
17
  # interactive console never blocks. Hook-piped JSON is EOF-terminated.
@@ -85,9 +86,10 @@ if ($TOTAL -eq 0) {
85
86
  exit 0
86
87
  }
87
88
 
88
- # advisory_report: the v2.43 status echo.
89
+ # Keep explicit reports, but omit routine success from automatic gate checks.
89
90
  function Write-AdvisoryReport {
90
91
  if ($COMPLETE -eq $TOTAL -and $TOTAL -gt 0) {
92
+ if ($Gate) { return }
91
93
  Write-Host ('[planning-with-files] ALL PHASES COMPLETE (' + $COMPLETE + '/' + $TOTAL + '). If the user has additional work, add new phases to task_plan.md before starting.')
92
94
  } else {
93
95
  Write-Host ('[planning-with-files] Task in progress (' + $COMPLETE + '/' + $TOTAL + ' phases complete). Update progress.md before stopping.')
@@ -21,8 +21,9 @@
21
21
  # 4. the block counter (<plan-dir>/.stop_blocks) is below cap (PWF_GATE_CAP, default 20)
22
22
  # 5. the ledger advanced since the last block (stall → allow stop)
23
23
  # When all hold, it emits a single-line block-decision JSON on stdout and
24
- # exits 0. Otherwise it falls back to advisory output and exits 0.
25
- # Without --gate, or in non-gated mode, behavior is byte-equivalent to v2.43.
24
+ # exits 0. Otherwise it reports incomplete plans and exits 0; completed
25
+ # plans stay silent in --gate mode, including legacy plans without .mode.
26
+ # Without --gate, the explicit advisory report is unchanged.
26
27
  #
27
28
  # Stdin handling: the Claude Code Stop hook pipes a JSON payload on stdin. To
28
29
  # avoid hanging when nothing is piped, stdin is read ONLY when fd 0 is not a
@@ -119,9 +120,11 @@ if [ "$TOTAL" -eq 0 ]; then
119
120
  exit 0
120
121
  fi
121
122
 
122
- # advisory_report: the v2.43 status echo. Always exit 0 after calling.
123
+ # Explicit status reports retain completion text. Automatic gate checks have
124
+ # nothing to report on success; keep evaluating all gate guards before here.
123
125
  advisory_report() {
124
126
  if [ "$COMPLETE" -eq "$TOTAL" ] && [ "$TOTAL" -gt 0 ]; then
127
+ [ "$GATE" -eq 1 ] && return 0
125
128
  echo "[planning-with-files] ALL PHASES COMPLETE ($COMPLETE/$TOTAL). If the user has additional work, add new phases to task_plan.md before starting."
126
129
  else
127
130
  echo "[planning-with-files] Task in progress ($COMPLETE/$TOTAL phases complete). Update progress.md before stopping."
@@ -1,5 +1,6 @@
1
1
  # Initialize planning files for a new session
2
2
  # Usage: .\init-session.ps1 [-Template TYPE] [project-name]
3
+ # .\init-session.ps1 -PlanDir # isolated plan with generated slug
3
4
  # .\init-session.ps1 -Autonomous # v3 autonomous mode (opt-in)
4
5
  # .\init-session.ps1 -Gated # v3 gated mode (opt-in, implies autonomous)
5
6
  # Templates: default, analytics
@@ -12,6 +13,7 @@
12
13
  param(
13
14
  [string]$ProjectName = "project",
14
15
  [string]$Template = "default",
16
+ [switch]$PlanDir,
15
17
  [switch]$Autonomous,
16
18
  [switch]$Gated
17
19
  )
@@ -38,7 +40,45 @@ function Get-Nonce {
38
40
  ($bytes | ForEach-Object { $_.ToString("x2") }) -join ""
39
41
  }
40
42
 
41
- Write-Host "Initializing planning files for: $ProjectName (template: $Template)"
43
+ function Get-PlanSlug([string]$Name) {
44
+ # -creplace: the case-insensitive -replace lets letters that .NET folds to
45
+ # ASCII (a dotted capital I, the Kelvin sign) survive into the plan id.
46
+ $slug = $Name.ToLowerInvariant() -creplace '[^a-z0-9]', '-'
47
+ $slug = $slug -creplace '-{2,}', '-'
48
+ $slug = $slug.Trim('-')
49
+ if ($slug.Length -gt 40) {
50
+ $slug = $slug.Substring(0, 40).TrimEnd('-')
51
+ }
52
+ # The resolvers and the selector only accept ASCII plan ids.
53
+ if ($slug -cnotmatch '^[a-z0-9-]*$') {
54
+ return ""
55
+ }
56
+ return $slug
57
+ }
58
+
59
+ function Get-ShortId {
60
+ return ([Guid]::NewGuid().ToString('N')).Substring(0, 8)
61
+ }
62
+
63
+ function Get-InheritedMode([string]$CurrentMode) {
64
+ $RootModePath = Join-Path (Get-Location).Path ".mode"
65
+ if (-not (Test-Path -LiteralPath $RootModePath)) {
66
+ return $CurrentMode
67
+ }
68
+ if ($CurrentMode -eq "gated") {
69
+ return $CurrentMode
70
+ }
71
+
72
+ $RootMode = Get-Content -LiteralPath $RootModePath -Raw -ErrorAction SilentlyContinue
73
+ # Case-sensitive like inherit_root_mode in init-session.sh and the injector.
74
+ if ($RootMode -cmatch 'gate') {
75
+ return "gated"
76
+ }
77
+ if ($RootMode -cmatch 'autonomous') {
78
+ return "autonomous"
79
+ }
80
+ return $CurrentMode
81
+ }
42
82
 
43
83
  # Validate template
44
84
  if ($Template -ne "default" -and $Template -ne "analytics") {
@@ -46,11 +86,98 @@ if ($Template -ne "default" -and $Template -ne "analytics") {
46
86
  $Template = "default"
47
87
  }
48
88
 
89
+ # Match init-session.sh: zero args (or an empty name) preserve legacy root
90
+ # mode. A positional project name or -PlanDir creates an isolated
91
+ # .planning/<date>-<slug>/ plan.
92
+ $NamedPlan = $PSBoundParameters.ContainsKey("ProjectName") -and -not [string]::IsNullOrEmpty($ProjectName)
93
+ $UsePlanDir = $PlanDir -or $NamedPlan
94
+ if ($UsePlanDir) {
95
+ $PlanningRoot = Join-Path (Get-Location).Path ".planning"
96
+ # Match init-session.sh: set-active-plan.ps1 owns every write to the
97
+ # shared pointer, so a named plan cannot be created without it.
98
+ $PlanSelector = Join-Path $ScriptDir "set-active-plan.ps1"
99
+ if (-not (Test-Path -LiteralPath $PlanSelector -PathType Leaf)) {
100
+ Write-Error "Error: set-active-plan.ps1 is required to create a named plan safely."
101
+ exit 1
102
+ }
103
+ New-Item -ItemType Directory -Path $PlanningRoot -Force | Out-Null
104
+ # Verify the physical planning root and the existing pointer before
105
+ # creating anything below it. A symlink or junction that escapes the
106
+ # project must not redirect init writes, and a linked or non-regular
107
+ # pointer must be refused before a plan directory exists on disk. A
108
+ # script that returns without exit leaves $LASTEXITCODE alone, so reset
109
+ # it first and treat a thrown error as a failure too.
110
+ $global:LASTEXITCODE = 0
111
+ try {
112
+ & $PlanSelector -VerifyRoot *> $null
113
+ } catch {
114
+ $global:LASTEXITCODE = 1
115
+ }
116
+ if ($LASTEXITCODE -ne 0) {
117
+ Write-Error "Error: the planning directory or the active plan pointer is outside the project or cannot be verified."
118
+ exit 1
119
+ }
120
+
121
+ if ($NamedPlan) {
122
+ $Slug = Get-PlanSlug $ProjectName
123
+ } else {
124
+ $Slug = ""
125
+ $ProjectName = "untitled"
126
+ }
127
+ if ([string]::IsNullOrEmpty($Slug)) {
128
+ $Slug = "untitled-$(Get-ShortId)"
129
+ }
130
+
131
+ $BaseId = "$DATE-$Slug"
132
+ $PlanId = $BaseId
133
+ $Counter = 2
134
+ while (Test-Path -LiteralPath (Join-Path $PlanningRoot $PlanId)) {
135
+ $PlanId = "$BaseId-$Counter"
136
+ $Counter++
137
+ }
138
+ $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
+ }
153
+ $Mode = Get-InheritedMode $Mode
154
+ } else {
155
+ $TargetDir = (Get-Location).Path
156
+ }
157
+
158
+ $TaskPlanPath = Join-Path $TargetDir "task_plan.md"
159
+ $FindingsPath = Join-Path $TargetDir "findings.md"
160
+ $ProgressPath = Join-Path $TargetDir "progress.md"
161
+ if ($UsePlanDir) {
162
+ $TaskPlanDisplay = $TaskPlanPath
163
+ $FindingsDisplay = $FindingsPath
164
+ $ProgressDisplay = $ProgressPath
165
+ } else {
166
+ $TaskPlanDisplay = "task_plan.md"
167
+ $FindingsDisplay = "findings.md"
168
+ $ProgressDisplay = "progress.md"
169
+ }
170
+
171
+ Write-Host "Initializing planning files for: $ProjectName (template: $Template)"
172
+ if ($UsePlanDir) {
173
+ Write-Host "PLAN_ID=$PlanId"
174
+ }
175
+
49
176
  # Create task_plan.md if it doesn't exist
50
- if (-not (Test-Path "task_plan.md")) {
177
+ if (-not (Test-Path -LiteralPath $TaskPlanPath)) {
51
178
  $AnalyticsPlan = Join-Path $TemplateDir "analytics_task_plan.md"
52
179
  if ($Template -eq "analytics" -and (Test-Path $AnalyticsPlan)) {
53
- Copy-Item $AnalyticsPlan "task_plan.md"
180
+ Copy-Item -LiteralPath $AnalyticsPlan -Destination $TaskPlanPath
54
181
  } else {
55
182
  @"
56
183
  # Task Plan: [Brief Description]
@@ -99,18 +226,18 @@ Phase 1
99
226
  ## Errors Encountered
100
227
  | Error | Resolution |
101
228
  |-------|------------|
102
- "@ | Out-File -FilePath "task_plan.md" -Encoding UTF8
229
+ "@ | Out-File -LiteralPath $TaskPlanPath -Encoding UTF8
103
230
  }
104
- Write-Host "Created task_plan.md"
231
+ Write-Host "Created $TaskPlanDisplay"
105
232
  } else {
106
- Write-Host "task_plan.md already exists, skipping"
233
+ Write-Host "$TaskPlanDisplay already exists, skipping"
107
234
  }
108
235
 
109
236
  # Create findings.md if it doesn't exist
110
- if (-not (Test-Path "findings.md")) {
237
+ if (-not (Test-Path -LiteralPath $FindingsPath)) {
111
238
  $AnalyticsFindings = Join-Path $TemplateDir "analytics_findings.md"
112
239
  if ($Template -eq "analytics" -and (Test-Path $AnalyticsFindings)) {
113
- Copy-Item $AnalyticsFindings "findings.md"
240
+ Copy-Item -LiteralPath $AnalyticsFindings -Destination $FindingsPath
114
241
  } else {
115
242
  @"
116
243
  # Findings & Decisions
@@ -131,15 +258,15 @@ if (-not (Test-Path "findings.md")) {
131
258
 
132
259
  ## Resources
133
260
  -
134
- "@ | Out-File -FilePath "findings.md" -Encoding UTF8
261
+ "@ | Out-File -LiteralPath $FindingsPath -Encoding UTF8
135
262
  }
136
- Write-Host "Created findings.md"
263
+ Write-Host "Created $FindingsDisplay"
137
264
  } else {
138
- Write-Host "findings.md already exists, skipping"
265
+ Write-Host "$FindingsDisplay already exists, skipping"
139
266
  }
140
267
 
141
268
  # Create progress.md if it doesn't exist
142
- if (-not (Test-Path "progress.md")) {
269
+ if (-not (Test-Path -LiteralPath $ProgressPath)) {
143
270
  if ($Template -eq "analytics") {
144
271
  @"
145
272
  # Progress Log
@@ -160,7 +287,7 @@ if (-not (Test-Path "progress.md")) {
160
287
  ### Errors
161
288
  | Error | Resolution |
162
289
  |-------|------------|
163
- "@ | Out-File -FilePath "progress.md" -Encoding UTF8
290
+ "@ | Out-File -LiteralPath $ProgressPath -Encoding UTF8
164
291
  } else {
165
292
  @"
166
293
  # Progress Log
@@ -181,23 +308,28 @@ if (-not (Test-Path "progress.md")) {
181
308
  ### Errors
182
309
  | Error | Resolution |
183
310
  |-------|------------|
184
- "@ | Out-File -FilePath "progress.md" -Encoding UTF8
311
+ "@ | Out-File -LiteralPath $ProgressPath -Encoding UTF8
185
312
  }
186
- Write-Host "Created progress.md"
313
+ Write-Host "Created $ProgressDisplay"
187
314
  } else {
188
- Write-Host "progress.md already exists, skipping"
315
+ Write-Host "$ProgressDisplay already exists, skipping"
189
316
  }
190
317
 
191
318
  Write-Host ""
192
319
  Write-Host "Planning files initialized!"
193
- Write-Host "Files: task_plan.md, findings.md, progress.md"
320
+ if ($UsePlanDir) {
321
+ Write-Host "Active plan recorded: $(Join-Path $PlanningRoot '.active_plan')"
322
+ Write-Host "Pin this terminal to the plan for parallel sessions:"
323
+ Write-Host " `$env:PLAN_ID='$PlanId'"
324
+ } else {
325
+ Write-Host "Files: task_plan.md, findings.md, progress.md"
326
+ }
194
327
 
195
328
  # v3 opt-in mode side effects. No-op when -Autonomous/-Gated were not passed, so
196
- # the default path stays byte-equivalent to v2.43.0. PS1 init writes in CWD, so
197
- # dotfiles live in CWD and attest-plan.ps1 falls back to the legacy
198
- # .plan-attestation at the project root.
329
+ # the default path stays byte-equivalent to v2.43.0. Dotfiles live beside the
330
+ # selected plan; root mode therefore retains the legacy project-root behavior.
199
331
  if ($Mode -ne "") {
200
- $PlanDirPwf = (Get-Location).Path
332
+ $PlanDirPwf = $TargetDir
201
333
 
202
334
  # (a) reset gate block counter, drop stale gate ledger.
203
335
  Set-Content -LiteralPath (Join-Path $PlanDirPwf ".stop_blocks") -Value "0" -Encoding ascii
@@ -216,13 +348,42 @@ if ($Mode -ne "") {
216
348
  Set-Content -LiteralPath (Join-Path $PlanDirPwf ".mode") -Value $MarkerText -Encoding ascii
217
349
 
218
350
  # (c) auto-attest (attestation default-on in v3 modes, security strand rec 1).
219
- $AttestPs1 = Join-Path $ScriptDir "attest-plan.ps1"
351
+ # attest-plan.ps1 intentionally refuses non-Windows hosts because its secure
352
+ # 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.
220
355
  $PlanFilePwf = Join-Path $PlanDirPwf "task_plan.md"
221
- if ((Test-Path -LiteralPath $AttestPs1) -and (Test-Path -LiteralPath $PlanFilePwf)) {
356
+ if (Test-Path -LiteralPath $PlanFilePwf) {
357
+ $HadPlanId = Test-Path Env:PLAN_ID
358
+ $PreviousPlanId = $env:PLAN_ID
222
359
  try {
223
- & $AttestPs1 *> $null
360
+ if ($UsePlanDir) {
361
+ $env:PLAN_ID = $PlanId
362
+ } else {
363
+ Remove-Item Env:PLAN_ID -ErrorAction SilentlyContinue
364
+ }
365
+
366
+ $IsWindowsHost = [Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT
367
+ if ($IsWindowsHost) {
368
+ $AttestPs1 = Join-Path $ScriptDir "attest-plan.ps1"
369
+ if (Test-Path -LiteralPath $AttestPs1) {
370
+ & $AttestPs1 *> $null
371
+ }
372
+ } else {
373
+ $AttestSh = Join-Path $ScriptDir "attest-plan.sh"
374
+ $Sh = Get-Command sh -ErrorAction SilentlyContinue
375
+ if ($Sh -and (Test-Path -LiteralPath $AttestSh)) {
376
+ & $Sh.Path $AttestSh *> $null
377
+ }
378
+ }
224
379
  } catch {
225
380
  # attestation failure must not abort init; the mode marker still stands.
381
+ } finally {
382
+ if ($HadPlanId) {
383
+ $env:PLAN_ID = $PreviousPlanId
384
+ } else {
385
+ Remove-Item Env:PLAN_ID -ErrorAction SilentlyContinue
386
+ }
226
387
  }
227
388
  }
228
389
 
@@ -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
 
@@ -382,6 +383,20 @@ if [ "$SLUG_MODE" -eq 1 ]; then
382
383
  BASE_ID="${DATE}-${SLUG}"
383
384
  PLAN_ID="$BASE_ID"
384
385
  PLAN_ROOT="${PWD}/.planning"
386
+ PLAN_SELECTOR="${SCRIPT_DIR}/set-active-plan.sh"
387
+ if [ ! -f "${PLAN_SELECTOR}" ]; then
388
+ echo "Error: set-active-plan.sh is required to create a named plan safely." >&2
389
+ exit 1
390
+ fi
391
+ mkdir -p "${PLAN_ROOT}"
392
+ # Verify the physical planning root and the existing pointer before
393
+ # creating anything below it. A symlink or junction that escapes the
394
+ # project must not redirect init writes, and a linked or non-regular
395
+ # pointer must be refused before a plan directory exists on disk. The
396
+ # selector's check is constant time; --list would parse every plan.
397
+ if ! sh "${PLAN_SELECTOR}" --verify-root; then
398
+ exit 1
399
+ fi
385
400
  counter=2
386
401
  while [ -d "${PLAN_ROOT}/${PLAN_ID}" ]; do
387
402
  PLAN_ID="${BASE_ID}-${counter}"
@@ -393,7 +408,13 @@ if [ "$SLUG_MODE" -eq 1 ]; then
393
408
  echo "Initializing planning files for: ${PROJECT_NAME:-untitled} (template: $TEMPLATE)"
394
409
  echo "PLAN_ID=$PLAN_ID"
395
410
  create_files_in "$PLAN_DIR"
396
- printf "%s\n" "$PLAN_ID" > "${PLAN_ROOT}/.active_plan"
411
+ # Reuse the selector's contained, atomic pointer replacement. Direct shell
412
+ # redirection would truncate a pre-existing hardlink and could overwrite a
413
+ # different file that shares the same inode.
414
+ if ! sh "${PLAN_SELECTOR}" "${PLAN_ID}" >/dev/null; then
415
+ echo "Error: could not safely update ${PLAN_ROOT}/.active_plan." >&2
416
+ exit 1
417
+ fi
397
418
  inherit_root_mode
398
419
  apply_v3_mode "$PLAN_DIR" "${PLAN_DIR}/task_plan.md"
399
420
  echo ""
@@ -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')
@@ -576,28 +573,42 @@ def opencode_catchup(project_path: str, mode: str = 'no-history') -> None:
576
573
  update_time = None
577
574
  update_idx = -1
578
575
  for idx, (sid, _) in enumerate(previous_sessions):
579
- params = (sid,) + PLANNING_LIKE_SQL
580
576
  cur.execute(
581
577
  """
582
- SELECT time_created FROM part
578
+ SELECT time_created, data FROM part
583
579
  WHERE session_id = ?
584
580
  AND json_extract(data, '$.type') = 'tool'
585
581
  AND lower(json_extract(data, '$.tool')) IN ('write', 'edit', 'patch')
586
582
  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 ?
583
+ replace(json_extract(data, '$.state.input.filePath'), char(92), '/')
584
+ IN ('task_plan.md', 'findings.md', 'progress.md')
585
+ OR replace(json_extract(data, '$.state.input.filePath'), char(92), '/')
586
+ GLOB '*/task_plan.md'
587
+ OR replace(json_extract(data, '$.state.input.filePath'), char(92), '/')
588
+ GLOB '*/findings.md'
589
+ OR replace(json_extract(data, '$.state.input.filePath'), char(92), '/')
590
+ GLOB '*/progress.md'
590
591
  )
591
- ORDER BY time_created DESC
592
- LIMIT 1
592
+ ORDER BY time_created DESC, id DESC
593
593
  """,
594
- params,
594
+ (sid,),
595
595
  )
596
- row = cur.fetchone()
597
- if row:
598
- update_sid = sid
599
- update_time = row[0]
600
- update_idx = idx
596
+ # Iterate lazily: write parts carry whole file bodies, and fetchall
597
+ # would materialize every planning write of the session before the
598
+ # first validated row ends the loop.
599
+ for candidate_time, data_str in cur:
600
+ data = json_loads(data_str)
601
+ if not isinstance(data, dict):
602
+ continue
603
+ state = data.get('state')
604
+ input_ = state.get('input') if isinstance(state, dict) else None
605
+ file_path = input_.get('filePath') if isinstance(input_, dict) else None
606
+ if planning_file_from_path(file_path):
607
+ update_sid = sid
608
+ update_time = candidate_time
609
+ update_idx = idx
610
+ break
611
+ if update_sid:
601
612
  break
602
613
 
603
614
  if not update_sid:
@@ -685,12 +696,17 @@ def parse_session_messages(session_file: Path) -> List[Dict[str, Any]]:
685
696
 
686
697
 
687
698
  def planning_file_from_path(path_value: Any) -> Optional[str]:
699
+ """Return a planning filename only when it is the path's exact basename.
700
+
701
+ A suffix check treats lookalikes such as ``draft_task_plan.md`` as real
702
+ planning updates and can anchor catchup at unrelated transcript content.
703
+ Normalize separators so the same boundary rule works for Unix and Windows
704
+ session records.
705
+ """
688
706
  if not isinstance(path_value, str):
689
707
  return None
690
- for pf in PLANNING_FILES:
691
- if path_value.endswith(pf):
692
- return pf
693
- return None
708
+ basename = path_value.replace(chr(92), '/').rsplit('/', 1)[-1]
709
+ return basename if basename in PLANNING_FILES else None
694
710
 
695
711
 
696
712
  def planning_file_from_paths(paths: Iterable[Any]) -> Optional[str]: