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.
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.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
@@ -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,65 @@ 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
+ }
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
+ }
42
102
 
43
103
  # Validate template
44
104
  if ($Template -ne "default" -and $Template -ne "analytics") {
@@ -46,11 +106,86 @@ if ($Template -ne "default" -and $Template -ne "analytics") {
46
106
  $Template = "default"
47
107
  }
48
108
 
109
+ # Match init-session.sh: zero args (or an empty name) preserve legacy root
110
+ # mode. A positional project name or -PlanDir creates an isolated
111
+ # .planning/<date>-<slug>/ plan.
112
+ $NamedPlan = $PSBoundParameters.ContainsKey("ProjectName") -and -not [string]::IsNullOrEmpty($ProjectName)
113
+ $UsePlanDir = $PlanDir -or $NamedPlan
114
+ if ($UsePlanDir) {
115
+ $PlanningRoot = Join-Path (Get-Location).Path ".planning"
116
+ # Match init-session.sh: set-active-plan.ps1 owns every write to the
117
+ # shared pointer, so a named plan cannot be created without it.
118
+ $PlanSelector = Join-Path $ScriptDir "set-active-plan.ps1"
119
+ if (-not (Test-Path -LiteralPath $PlanSelector -PathType Leaf)) {
120
+ Write-Error "Error: set-active-plan.ps1 is required to create a named plan safely."
121
+ exit 1
122
+ }
123
+ New-Item -ItemType Directory -Path $PlanningRoot -Force | Out-Null
124
+ # Verify the physical planning root and the existing pointer before
125
+ # creating anything below it. A symlink or junction that escapes the
126
+ # project must not redirect init writes, and a linked or non-regular
127
+ # pointer must be refused before a plan directory exists on disk. A
128
+ # script that returns without exit leaves $LASTEXITCODE alone, so reset
129
+ # it first and treat a thrown error as a failure too.
130
+ $global:LASTEXITCODE = 0
131
+ try {
132
+ & $PlanSelector -VerifyRoot *> $null
133
+ } catch {
134
+ $global:LASTEXITCODE = 1
135
+ }
136
+ if ($LASTEXITCODE -ne 0) {
137
+ Write-Error "Error: the planning directory or the active plan pointer is outside the project or cannot be verified."
138
+ exit 1
139
+ }
140
+
141
+ if ($NamedPlan) {
142
+ $Slug = Get-PlanSlug $ProjectName
143
+ } else {
144
+ $Slug = ""
145
+ $ProjectName = "untitled"
146
+ }
147
+ if ([string]::IsNullOrEmpty($Slug)) {
148
+ $Slug = "untitled-$(Get-ShortId)"
149
+ }
150
+
151
+ $BaseId = "$DATE-$Slug"
152
+ $PlanId = $BaseId
153
+ $Counter = 2
154
+ while (Test-Path -LiteralPath (Join-Path $PlanningRoot $PlanId)) {
155
+ $PlanId = "$BaseId-$Counter"
156
+ $Counter++
157
+ }
158
+ $TargetDir = Join-Path $PlanningRoot $PlanId
159
+ New-Item -ItemType Directory -Path $TargetDir -Force -ErrorAction Stop | Out-Null
160
+ $Mode = Get-InheritedMode $Mode
161
+ } else {
162
+ $TargetDir = (Get-Location).Path
163
+ }
164
+
165
+ $TaskPlanPath = Join-Path $TargetDir "task_plan.md"
166
+ $FindingsPath = Join-Path $TargetDir "findings.md"
167
+ $ProgressPath = Join-Path $TargetDir "progress.md"
168
+ if ($UsePlanDir) {
169
+ $TaskPlanDisplay = $TaskPlanPath
170
+ $FindingsDisplay = $FindingsPath
171
+ $ProgressDisplay = $ProgressPath
172
+ } else {
173
+ $TaskPlanDisplay = "task_plan.md"
174
+ $FindingsDisplay = "findings.md"
175
+ $ProgressDisplay = "progress.md"
176
+ }
177
+
178
+ Write-Host "Initializing planning files for: $ProjectName (template: $Template)"
179
+ if ($UsePlanDir) {
180
+ Write-Host "PLAN_ID=$PlanId"
181
+ }
182
+
183
+ try {
49
184
  # Create task_plan.md if it doesn't exist
50
- if (-not (Test-Path "task_plan.md")) {
185
+ if (-not (Test-Path -LiteralPath $TaskPlanPath)) {
51
186
  $AnalyticsPlan = Join-Path $TemplateDir "analytics_task_plan.md"
52
187
  if ($Template -eq "analytics" -and (Test-Path $AnalyticsPlan)) {
53
- Copy-Item $AnalyticsPlan "task_plan.md"
188
+ Copy-Item -LiteralPath $AnalyticsPlan -Destination $TaskPlanPath -ErrorAction Stop
54
189
  } else {
55
190
  @"
56
191
  # Task Plan: [Brief Description]
@@ -99,18 +234,18 @@ Phase 1
99
234
  ## Errors Encountered
100
235
  | Error | Resolution |
101
236
  |-------|------------|
102
- "@ | Out-File -FilePath "task_plan.md" -Encoding UTF8
237
+ "@ | Out-File -LiteralPath $TaskPlanPath -Encoding UTF8 -ErrorAction Stop
103
238
  }
104
- Write-Host "Created task_plan.md"
239
+ Write-Host "Created $TaskPlanDisplay"
105
240
  } else {
106
- Write-Host "task_plan.md already exists, skipping"
241
+ Write-Host "$TaskPlanDisplay already exists, skipping"
107
242
  }
108
243
 
109
244
  # Create findings.md if it doesn't exist
110
- if (-not (Test-Path "findings.md")) {
245
+ if (-not (Test-Path -LiteralPath $FindingsPath)) {
111
246
  $AnalyticsFindings = Join-Path $TemplateDir "analytics_findings.md"
112
247
  if ($Template -eq "analytics" -and (Test-Path $AnalyticsFindings)) {
113
- Copy-Item $AnalyticsFindings "findings.md"
248
+ Copy-Item -LiteralPath $AnalyticsFindings -Destination $FindingsPath -ErrorAction Stop
114
249
  } else {
115
250
  @"
116
251
  # Findings & Decisions
@@ -131,15 +266,15 @@ if (-not (Test-Path "findings.md")) {
131
266
 
132
267
  ## Resources
133
268
  -
134
- "@ | Out-File -FilePath "findings.md" -Encoding UTF8
269
+ "@ | Out-File -LiteralPath $FindingsPath -Encoding UTF8 -ErrorAction Stop
135
270
  }
136
- Write-Host "Created findings.md"
271
+ Write-Host "Created $FindingsDisplay"
137
272
  } else {
138
- Write-Host "findings.md already exists, skipping"
273
+ Write-Host "$FindingsDisplay already exists, skipping"
139
274
  }
140
275
 
141
276
  # Create progress.md if it doesn't exist
142
- if (-not (Test-Path "progress.md")) {
277
+ if (-not (Test-Path -LiteralPath $ProgressPath)) {
143
278
  if ($Template -eq "analytics") {
144
279
  @"
145
280
  # Progress Log
@@ -160,7 +295,7 @@ if (-not (Test-Path "progress.md")) {
160
295
  ### Errors
161
296
  | Error | Resolution |
162
297
  |-------|------------|
163
- "@ | Out-File -FilePath "progress.md" -Encoding UTF8
298
+ "@ | Out-File -LiteralPath $ProgressPath -Encoding UTF8 -ErrorAction Stop
164
299
  } else {
165
300
  @"
166
301
  # Progress Log
@@ -181,23 +316,48 @@ if (-not (Test-Path "progress.md")) {
181
316
  ### Errors
182
317
  | Error | Resolution |
183
318
  |-------|------------|
184
- "@ | Out-File -FilePath "progress.md" -Encoding UTF8
319
+ "@ | Out-File -LiteralPath $ProgressPath -Encoding UTF8 -ErrorAction Stop
185
320
  }
186
- Write-Host "Created progress.md"
321
+ Write-Host "Created $ProgressDisplay"
187
322
  } else {
188
- Write-Host "progress.md already exists, skipping"
323
+ Write-Host "$ProgressDisplay already exists, skipping"
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
+ }
189
344
  }
190
345
 
191
346
  Write-Host ""
192
347
  Write-Host "Planning files initialized!"
193
- Write-Host "Files: task_plan.md, findings.md, progress.md"
348
+ if ($UsePlanDir) {
349
+ Write-Host "Active plan recorded: $(Join-Path $PlanningRoot '.active_plan')"
350
+ Write-Host "Pin this terminal to the plan for parallel sessions:"
351
+ Write-Host " `$env:PLAN_ID='$PlanId'"
352
+ } else {
353
+ Write-Host "Files: task_plan.md, findings.md, progress.md"
354
+ }
194
355
 
195
356
  # 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.
357
+ # the default path stays byte-equivalent to v2.43.0. Dotfiles live beside the
358
+ # selected plan; root mode therefore retains the legacy project-root behavior.
199
359
  if ($Mode -ne "") {
200
- $PlanDirPwf = (Get-Location).Path
360
+ $PlanDirPwf = $TargetDir
201
361
 
202
362
  # (a) reset gate block counter, drop stale gate ledger.
203
363
  Set-Content -LiteralPath (Join-Path $PlanDirPwf ".stop_blocks") -Value "0" -Encoding ascii
@@ -216,15 +376,96 @@ if ($Mode -ne "") {
216
376
  Set-Content -LiteralPath (Join-Path $PlanDirPwf ".mode") -Value $MarkerText -Encoding ascii
217
377
 
218
378
  # (c) auto-attest (attestation default-on in v3 modes, security strand rec 1).
219
- $AttestPs1 = Join-Path $ScriptDir "attest-plan.ps1"
379
+ # attest-plan.ps1 intentionally refuses non-Windows hosts because its secure
380
+ # no-follow implementation uses Win32 handles. On Unix, use the POSIX
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"
220
389
  $PlanFilePwf = Join-Path $PlanDirPwf "task_plan.md"
221
- if ((Test-Path -LiteralPath $AttestPs1) -and (Test-Path -LiteralPath $PlanFilePwf)) {
390
+ if (Test-Path -LiteralPath $PlanFilePwf -PathType Leaf) {
391
+ $HadPlanId = Test-Path Env:PLAN_ID
392
+ $PreviousPlanId = $env:PLAN_ID
393
+ $HadPlanRoot = Test-Path Env:PWF_PLAN_ROOT
394
+ $PreviousPlanRoot = $env:PWF_PLAN_ROOT
222
395
  try {
223
- & $AttestPs1 *> $null
396
+ if ($UsePlanDir) {
397
+ $env:PWF_PLAN_ROOT = (Get-Location).Path
398
+ $env:PLAN_ID = $PlanId
399
+ } else {
400
+ Remove-Item Env:PWF_PLAN_ROOT -ErrorAction SilentlyContinue
401
+ Remove-Item Env:PLAN_ID -ErrorAction SilentlyContinue
402
+ }
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 = @()
409
+ $IsWindowsHost = [Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT
410
+ if ($IsWindowsHost) {
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"
426
+ }
427
+ } else {
428
+ $AttestationCommand = "attest-plan.sh"
429
+ $AttestSh = Join-Path $ScriptDir $AttestationCommand
430
+ $Sh = Get-Command sh -ErrorAction SilentlyContinue
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"
446
+ }
447
+ }
224
448
  } catch {
225
- # attestation failure must not abort init; the mode marker still stands.
449
+ $AttestationReason = Format-AttestationFailureReason `
450
+ -Output @($_) `
451
+ -Fallback "$AttestationCommand failed"
452
+ } finally {
453
+ if ($HadPlanId) {
454
+ $env:PLAN_ID = $PreviousPlanId
455
+ } else {
456
+ Remove-Item Env:PLAN_ID -ErrorAction SilentlyContinue
457
+ }
458
+ if ($HadPlanRoot) {
459
+ $env:PWF_PLAN_ROOT = $PreviousPlanRoot
460
+ } else {
461
+ Remove-Item Env:PWF_PLAN_ROOT -ErrorAction SilentlyContinue
462
+ }
226
463
  }
227
464
  }
228
465
 
229
- 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
+ }
230
471
  }