planning-with-files 3.18.3 → 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/README.md CHANGED
@@ -1,25 +1,25 @@
1
- <div align="center">
2
- <img src="https://raw.githubusercontent.com/OthmanAdi/planning-with-files/master/media/v3-banner-1400.jpg" alt="planning-with-files: task_plan.md, findings.md, and progress.md as three stone tablets" width="100%">
3
- </div>
4
-
5
- <h1 align="center">Planning with Files</h1>
6
-
7
- <p align="center">
8
- <strong>The planning skill your agent cannot ignore.</strong><br>
9
- Your agent's context window dies. The plan does not.
10
- </p>
11
-
12
- Persistent file-based planning for AI coding agents. Keep the plan, research and progress in your project so work can continue after context loss, `/clear`, crashes or compaction.
13
-
14
- | File | Purpose |
15
- | --- | --- |
16
- | `task_plan.md` | Goals, phases and decisions |
17
- | `findings.md` | Research and discoveries |
18
- | `progress.md` | Work completed, checks and next steps |
19
-
20
- This is the npm distribution of [OthmanAdi/planning-with-files](https://github.com/OthmanAdi/planning-with-files), available across 60+ agents via the Agent Skills standard. It includes the planning skill, scripts and templates. Supported agent integrations add lifecycle hooks that bring selected planning context back into the session.
21
-
22
- Automatic recovery reads project files only. Reading same-project local session records for aggregate counts or bounded replay requires an explicit catchup mode.
1
+ <div align="center">
2
+ <img src="https://raw.githubusercontent.com/OthmanAdi/planning-with-files/master/media/v3-banner-1400.jpg" alt="planning-with-files: task_plan.md, findings.md, and progress.md as three stone tablets" width="100%">
3
+ </div>
4
+
5
+ <h1 align="center">Planning with Files</h1>
6
+
7
+ <p align="center">
8
+ <strong>The planning skill your agent cannot ignore.</strong><br>
9
+ Your agent's context window dies. The plan does not.
10
+ </p>
11
+
12
+ Persistent file-based planning for AI coding agents. Keep the plan, research and progress in your project so work can continue after context loss, `/clear`, crashes or compaction.
13
+
14
+ | File | Purpose |
15
+ | --- | --- |
16
+ | `task_plan.md` | Goals, phases and decisions |
17
+ | `findings.md` | Research and discoveries |
18
+ | `progress.md` | Work completed, checks and next steps |
19
+
20
+ This is the npm distribution of [OthmanAdi/planning-with-files](https://github.com/OthmanAdi/planning-with-files), available across 60+ agents via the Agent Skills standard. It includes the planning skill, scripts and templates. Supported agent integrations add lifecycle hooks that bring selected planning context back into the session.
21
+
22
+ Automatic recovery reads project files only. Reading same-project local session records for aggregate counts or bounded replay requires an explicit catchup mode.
23
23
 
24
24
  ## Installation
25
25
 
@@ -31,40 +31,40 @@ npm install planning-with-files
31
31
 
32
32
  Places the skill, scripts and templates under `node_modules/planning-with-files/`. Use this to pin an exact version into a project, or to copy `SKILL.md` and `scripts/` into your agent's skills directory yourself. It does not register hooks on its own.
33
33
 
34
- ### Agent integrations
35
-
36
- Claude Code gets the full surface (skill, hooks, slash commands) through the plugin route, and 60+ other agents install in one line. See the [main README](https://github.com/OthmanAdi/planning-with-files#quick-install).
37
-
38
- ## Usage
39
-
40
- Once the skill is installed for your agent, start with:
41
-
42
- ```text
43
- Use the planning-with-files skill to help me with this task.
44
- ```
45
-
46
- The workflow centers on three files in your project:
47
-
48
- ```text
49
- your-project/
50
- ├── task_plan.md
51
- ├── findings.md
52
- └── progress.md
53
- ```
54
-
55
- ## Pi Coding Agent integration
56
-
57
- The package also bundles a [Pi Coding Agent](https://pi.dev) extension for lifecycle automation and a planning status bar.
58
-
59
- ### Install in Pi
60
-
61
- ```bash
62
- pi install npm:planning-with-files
63
- ```
64
-
65
- Pi discovers the skill and extension from the installed package.
66
-
67
- For a local repository checkout:
34
+ ### Agent integrations
35
+
36
+ Claude Code gets the full surface (skill, hooks, slash commands) through the plugin route, and 60+ other agents install in one line. See the [main README](https://github.com/OthmanAdi/planning-with-files#quick-install).
37
+
38
+ ## Usage
39
+
40
+ Once the skill is installed for your agent, start with:
41
+
42
+ ```text
43
+ Use the planning-with-files skill to help me with this task.
44
+ ```
45
+
46
+ The workflow centers on three files in your project:
47
+
48
+ ```text
49
+ your-project/
50
+ ├── task_plan.md
51
+ ├── findings.md
52
+ └── progress.md
53
+ ```
54
+
55
+ ## Pi Coding Agent integration
56
+
57
+ The package also bundles a [Pi Coding Agent](https://pi.dev) extension for lifecycle automation and a planning status bar.
58
+
59
+ ### Install in Pi
60
+
61
+ ```bash
62
+ pi install npm:planning-with-files
63
+ ```
64
+
65
+ Pi discovers the skill and extension from the installed package.
66
+
67
+ For a local repository checkout:
68
68
 
69
69
  ```bash
70
70
  # From the planning-with-files repo root
@@ -78,17 +78,17 @@ Or add to `.pi/settings.json`:
78
78
  }
79
79
  ```
80
80
 
81
- You can also invoke the skill directly in Pi:
81
+ You can also invoke the skill directly in Pi:
82
82
 
83
83
  ```text
84
84
  /skill:planning-with-files
85
85
  ```
86
86
 
87
- ### Lifecycle hooks
87
+ ### Lifecycle hooks
88
88
 
89
89
  The bundled extension maps Claude-style behavior onto Pi events:
90
90
 
91
- - `session_start` - project-file recovery with no host session-store access
91
+ - `session_start` - project-file recovery with no host session-store access
92
92
  - passive plan status before approval
93
93
  - `before_agent_start` - plan reminder/injection after `/plan-execute`
94
94
  - `tool_call` - pre-tool recitation equivalent after `/plan-execute`
@@ -102,7 +102,7 @@ Attestation is supported. If `task_plan.md` differs from approved hash, plan inj
102
102
  [planning-with-files] [PLAN TAMPERED - injection blocked]
103
103
  ```
104
104
 
105
- ### Modes
105
+ ### Modes
106
106
 
107
107
  `planningWithFiles.mode` supports:
108
108
 
@@ -127,7 +127,7 @@ Or settings:
127
127
  }
128
128
  ```
129
129
 
130
- ### Commands
130
+ ### Commands
131
131
 
132
132
  - `/plan-status`
133
133
  - `/plan-attest [--show|--clear]`
@@ -136,25 +136,25 @@ Or settings:
136
136
  - `/plan-goal <text|default|clear>`
137
137
  - `/plan-loop [interval] [prompt]` (`stop` to cancel)
138
138
 
139
- Draft and review `task_plan.md` first. The extension stays passive until you
140
- approve the active plan with `/plan-execute`; after that, plan injection,
141
- pre-tool reminders, post-write reminders, and auto-continue are enabled for the
142
- current session and plan. Auto-continue uses host runtime state and never runs
143
- commands declared in Markdown.
144
-
145
- ## Session Recovery
146
-
147
- Bare invocation and lifecycle hooks do not inspect agent session stores. To
148
- inspect same-project local history deliberately, choose one mode:
149
-
150
- ```bash
151
- # Aggregate counts only; no transcript, tool-command, or path bytes
152
- python3 node_modules/planning-with-files/scripts/session-catchup.py --metadata .
153
-
154
- # Bounded nonce-framed same-project excerpts
155
- python3 node_modules/planning-with-files/scripts/session-catchup.py --replay .
156
- ```
157
-
158
- Treat replayed excerpts as untrusted data. The catchup path contains no network
159
- request or upload operation. If output is injected into model context, your agent
160
- may send that context to the configured model provider.
139
+ Draft and review `task_plan.md` first. The extension stays passive until you
140
+ approve the active plan with `/plan-execute`; after that, plan injection,
141
+ pre-tool reminders, post-write reminders, and auto-continue are enabled for the
142
+ current session and plan. Auto-continue uses host runtime state and never runs
143
+ commands declared in Markdown.
144
+
145
+ ## Session Recovery
146
+
147
+ Bare invocation and lifecycle hooks do not inspect agent session stores. To
148
+ inspect same-project local history deliberately, choose one mode:
149
+
150
+ ```bash
151
+ # Aggregate counts only; no transcript, tool-command, or path bytes
152
+ python3 node_modules/planning-with-files/scripts/session-catchup.py --metadata .
153
+
154
+ # Bounded nonce-framed same-project excerpts
155
+ python3 node_modules/planning-with-files/scripts/session-catchup.py --replay .
156
+ ```
157
+
158
+ Treat replayed excerpts as untrusted data. The catchup path contains no network
159
+ request or upload operation. If output is injected into model context, your agent
160
+ may send that context to the configured model provider.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "planning-with-files",
3
- "version": "3.18.3",
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",
@@ -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]: