planning-with-files 3.19.0 → 3.20.6

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/SKILL.md CHANGED
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: pi-planning-with-files
2
+ name: planning-with-files
3
3
  description: "Persistent file-based planning for multi-step AI-agent work. Keeps task_plan.md, findings.md, and progress.md on disk; lifecycle hooks inject selected project planning context. Automatic recovery reads project planning files only. Explicit session-catchup.py --metadata reads same-project local agent session records and emits aggregate counts only; --replay may emit bounded nonce-framed excerpts. Optional gated mode can request continuation only when the host supports it and never runs commands declared in Markdown. The skill has no network upload path. Use for research or work needing 5+ tool calls."
4
4
  user-invocable: true
5
5
  allowed-tools: "Read Write Edit Bash Glob Grep"
@@ -7,26 +7,28 @@ hooks:
7
7
  UserPromptSubmit:
8
8
  - hooks:
9
9
  - type: command
10
- command: "SH=\"${CLAUDE_SKILL_DIR}/scripts/skill-hook.sh\"; [ -f \"$SH\" ] || SH=$(ls \"$HOME/.claude/skills/planning-with-files/scripts/skill-hook.sh\" \"$HOME/.claude/plugins/marketplaces/planning-with-files/scripts/skill-hook.sh\" 2>/dev/null | head -1); [ -n \"$SH\" ] && [ -f \"$SH\" ] && sh \"$SH\" --event=userprompt; exit 0"
10
+ command: "[ -n \"${CLAUDE_PLUGIN_ROOT:-}\" ] && exit 0; SH=\"${CLAUDE_SKILL_DIR}/scripts/skill-hook.sh\"; [ -f \"$SH\" ] || SH=$(ls \"$HOME/.claude/skills/planning-with-files/scripts/skill-hook.sh\" \"$HOME/.claude/plugins/marketplaces/planning-with-files/scripts/skill-hook.sh\" 2>/dev/null | head -1); [ -n \"$SH\" ] && [ -f \"$SH\" ] && sh \"$SH\" --event=userprompt; exit 0"
11
11
  PreToolUse:
12
12
  - matcher: "Write|Edit|Bash|Read|Glob|Grep"
13
13
  hooks:
14
14
  - type: command
15
- command: "SH=\"${CLAUDE_SKILL_DIR}/scripts/skill-hook.sh\"; [ -f \"$SH\" ] || SH=$(ls \"$HOME/.claude/skills/planning-with-files/scripts/skill-hook.sh\" \"$HOME/.claude/plugins/marketplaces/planning-with-files/scripts/skill-hook.sh\" 2>/dev/null | head -1); [ -n \"$SH\" ] && [ -f \"$SH\" ] && sh \"$SH\" --event=pretool; exit 0"
15
+ command: "[ -n \"${CLAUDE_PLUGIN_ROOT:-}\" ] && exit 0; SH=\"${CLAUDE_SKILL_DIR}/scripts/skill-hook.sh\"; [ -f \"$SH\" ] || SH=$(ls \"$HOME/.claude/skills/planning-with-files/scripts/skill-hook.sh\" \"$HOME/.claude/plugins/marketplaces/planning-with-files/scripts/skill-hook.sh\" 2>/dev/null | head -1); [ -n \"$SH\" ] && [ -f \"$SH\" ] && sh \"$SH\" --event=pretool; exit 0"
16
16
  PostToolUse:
17
17
  - matcher: "Write|Edit"
18
18
  hooks:
19
19
  - type: command
20
- command: "SH=\"${CLAUDE_SKILL_DIR}/scripts/skill-hook.sh\"; [ -f \"$SH\" ] || SH=$(ls \"$HOME/.claude/skills/planning-with-files/scripts/skill-hook.sh\" \"$HOME/.claude/plugins/marketplaces/planning-with-files/scripts/skill-hook.sh\" 2>/dev/null | head -1); [ -n \"$SH\" ] && [ -f \"$SH\" ] && sh \"$SH\" --event=posttool; exit 0"
20
+ command: "[ -n \"${CLAUDE_PLUGIN_ROOT:-}\" ] && exit 0; SH=\"${CLAUDE_SKILL_DIR}/scripts/skill-hook.sh\"; [ -f \"$SH\" ] || SH=$(ls \"$HOME/.claude/skills/planning-with-files/scripts/skill-hook.sh\" \"$HOME/.claude/plugins/marketplaces/planning-with-files/scripts/skill-hook.sh\" 2>/dev/null | head -1); [ -n \"$SH\" ] && [ -f \"$SH\" ] && sh \"$SH\" --event=posttool; exit 0"
21
21
  Stop:
22
22
  - hooks:
23
23
  - type: command
24
- command: "SH=\"${CLAUDE_SKILL_DIR}/scripts/skill-hook.sh\"; [ -f \"$SH\" ] || SH=$(ls \"$HOME/.claude/skills/planning-with-files/scripts/skill-hook.sh\" \"$HOME/.claude/plugins/marketplaces/planning-with-files/scripts/skill-hook.sh\" 2>/dev/null | head -1); [ -n \"$SH\" ] && [ -f \"$SH\" ] && sh \"$SH\" --event=stop; exit 0"
24
+ command: "[ -n \"${CLAUDE_PLUGIN_ROOT:-}\" ] && exit 0; SH=\"${CLAUDE_SKILL_DIR}/scripts/skill-hook.sh\"; [ -f \"$SH\" ] || SH=$(ls \"$HOME/.claude/skills/planning-with-files/scripts/skill-hook.sh\" \"$HOME/.claude/plugins/marketplaces/planning-with-files/scripts/skill-hook.sh\" 2>/dev/null | head -1); [ -n \"$SH\" ] && [ -f \"$SH\" ] && sh \"$SH\" --event=stop; exit 0"
25
25
  PreCompact:
26
26
  - matcher: "*"
27
27
  hooks:
28
28
  - type: command
29
- command: "SH=\"${CLAUDE_SKILL_DIR}/scripts/skill-hook.sh\"; [ -f \"$SH\" ] || SH=$(ls \"$HOME/.claude/skills/planning-with-files/scripts/skill-hook.sh\" \"$HOME/.claude/plugins/marketplaces/planning-with-files/scripts/skill-hook.sh\" 2>/dev/null | head -1); [ -n \"$SH\" ] && [ -f \"$SH\" ] && sh \"$SH\" --event=precompact; exit 0"
29
+ command: "[ -n \"${CLAUDE_PLUGIN_ROOT:-}\" ] && exit 0; SH=\"${CLAUDE_SKILL_DIR}/scripts/skill-hook.sh\"; [ -f \"$SH\" ] || SH=$(ls \"$HOME/.claude/skills/planning-with-files/scripts/skill-hook.sh\" \"$HOME/.claude/plugins/marketplaces/planning-with-files/scripts/skill-hook.sh\" 2>/dev/null | head -1); [ -n \"$SH\" ] && [ -f \"$SH\" ] && sh \"$SH\" --event=precompact; exit 0"
30
+ metadata:
31
+ version: "3.20.6"
30
32
  ---
31
33
 
32
34
  # Planning with Files
@@ -221,7 +223,7 @@ Copy these templates to start:
221
223
  Helper scripts for automation:
222
224
 
223
225
  - `scripts/init-session.sh` — Initialize planning files. With a name arg, creates an isolated plan under `.planning/YYYY-MM-DD-<slug>/` for parallel task workflows. Without args, writes `task_plan.md` at project root (legacy mode, backward-compatible).
224
- - `scripts/set-active-plan.sh` — Switch the active plan pointer (`.planning/.active_plan`). Run with a plan ID to switch; run without args to show which plan is current.
226
+ - `scripts/set-active-plan.sh` — Switch or inspect the active plan pointer (`.planning/.active_plan`). Run with `--list` to show named plans and phase counts, with a plan ID to switch, or without args to show which plan is current.
225
227
  - `scripts/resolve-plan-dir.sh` — Resolve the active plan directory. A set `$PLAN_ID` is a binding: it resolves or resolution stops, never another plan (issue #237). With no `$PLAN_ID`, multiple named plans refuse selection. A single named plan may use `.planning/.active_plan` or discovery by mtime; otherwise resolution falls back to the project root (legacy). Used internally by hooks.
226
228
  - `scripts/check-complete.sh` — Verify all phases in the active plan are complete.
227
229
  - `scripts/session-catchup.py`: Explicit same-project session-record aggregation or bounded replay (`--metadata` / `--replay`); bare invocation does not access host history.
@@ -284,11 +286,11 @@ Not every install path ships every surface in this section. Two distinct install
284
286
  | `/plugin marketplace add OthmanAdi/planning-with-files` then `/plugin install` | SKILL.md, scripts, templates, **plus `commands/` folder** | Yes, as `/plan-goal` and `/plan-loop` |
285
287
  | `npx skills add OthmanAdi/planning-with-files` (or ClawHub) | SKILL.md, scripts, templates only | No, follow the manual fallback below |
286
288
 
287
- The PreCompact hook is registered in the SKILL.md frontmatter and works for both routes. The `/plan-goal` and `/plan-loop` slash commands live in `commands/` at the repo root, which only the plugin route copies into `~/.claude/plugins/marketplaces/`. Skill-only installs land at `~/.claude/skills/planning-with-files/` and do not see `commands/`.
289
+ Plugin installs register six lifecycle events from `hooks/hooks.json`, including quiet `SessionStart` recovery. Standalone skill installs register the five hooks in this SKILL.md frontmatter only after the skill is invoked for that session, so they have no startup recovery. The `/plan-goal` and `/plan-loop` slash commands live in `commands/` at the repository root and are available from the versioned plugin cache. Skill-only installs land at `~/.claude/skills/planning-with-files/` and do not include `commands/`.
288
290
 
289
291
  The standalone `scripts/skill-hook.sh` reads the host's JSON session identity. UserPromptSubmit emits plain context; PreToolUse and PostToolUse emit the event's `additionalContext` JSON. The progress reminder fires at most once per turn when a usable session identity and private cache are available, and repeats when those are unavailable. All five events follow the same plan selection and opt-out checks.
290
292
 
291
- Both slash commands also carry `disable-model-invocation: true`, which means the model will not auto-trigger them. You type them. Per known Claude Code behavior (anthropics/claude-code issues #26251, #41417), some sessions interpret `disable-model-invocation: true` as "I cannot use the Skill tool for this entry at all" and refuse to fire even when you type the slash. If that happens, the manual fallback below produces the same effect.
293
+ Both slash commands carry `disable-model-invocation: true`, so invoke them explicitly. If a command is unavailable on a skill-only install, the manual fallback below produces the same planning-file result.
292
294
 
293
295
  ### PreCompact hook (auto)
294
296
 
package/package.json CHANGED
@@ -1,10 +1,9 @@
1
1
  {
2
2
  "name": "planning-with-files",
3
- "version": "3.19.0",
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.",
3
+ "version": "3.20.6",
4
+ "description": "Official Planning with Files skill for AI coding agents. Ships the canonical skill, scripts, templates, and a Pi Coding Agent extension.",
5
5
  "keywords": [
6
- "pi-package",
7
- "pi-skill",
6
+ "planning-with-files",
8
7
  "planning",
9
8
  "manus",
10
9
  "agent",
@@ -14,7 +13,8 @@
14
13
  "coding-agent",
15
14
  "context-engineering",
16
15
  "session-recovery",
17
- "long-running-agents"
16
+ "long-running-agents",
17
+ "pi-coding-agent"
18
18
  ],
19
19
  "pi": {
20
20
  "skills": [
@@ -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
@@ -18,6 +18,27 @@ param(
18
18
  [switch]$Gated
19
19
  )
20
20
 
21
+ # Windows PowerShell 5.1 can silently relocate a -File invocation when the
22
+ # inherited cwd contains wildcard characters such as [ or ]. Recover the
23
+ # physical cwd for a direct invocation before any project-relative paths are
24
+ # resolved.
25
+ if ($PSVersionTable.PSVersion.Major -eq 5 -and
26
+ [System.Management.Automation.WildcardPattern]::ContainsWildcardCharacters([Environment]::CurrentDirectory)) {
27
+ $processArgs = [Environment]::GetCommandLineArgs()
28
+ for ($index = 0; $index -lt ($processArgs.Length - 1); $index++) {
29
+ if ($processArgs[$index] -ieq '-File') {
30
+ $entryScript = $processArgs[$index + 1]
31
+ if (-not [IO.Path]::IsPathRooted($entryScript)) {
32
+ $entryScript = Join-Path ([Environment]::CurrentDirectory) $entryScript
33
+ }
34
+ if ([IO.Path]::GetFullPath($entryScript) -eq [IO.Path]::GetFullPath($PSCommandPath)) {
35
+ Set-Location -LiteralPath ([Environment]::CurrentDirectory)
36
+ }
37
+ break
38
+ }
39
+ }
40
+ }
41
+
21
42
  $DATE = Get-Date -Format "yyyy-MM-dd"
22
43
 
23
44
  # Resolve v3 opt-in mode. -Gated implies autonomous and is the stronger marker.
@@ -80,6 +101,26 @@ function Get-InheritedMode([string]$CurrentMode) {
80
101
  return $CurrentMode
81
102
  }
82
103
 
104
+ function Format-AttestationFailureReason {
105
+ param([object[]]$Output, [string]$Fallback)
106
+
107
+ $Reason = @(
108
+ $Output |
109
+ ForEach-Object {
110
+ if ($null -ne $_) { ($_.ToString()).Trim() }
111
+ } |
112
+ Where-Object { $_ }
113
+ ) -join " "
114
+ $Reason = ($Reason -replace '\s+', ' ').Trim()
115
+ if ([string]::IsNullOrWhiteSpace($Reason)) {
116
+ return $Fallback
117
+ }
118
+ if ($Reason.Length -gt 300) {
119
+ return ($Reason.Substring(0, 297) + "...")
120
+ }
121
+ return $Reason
122
+ }
123
+
83
124
  # Validate template
84
125
  if ($Template -ne "default" -and $Template -ne "analytics") {
85
126
  Write-Host "Unknown template: $Template (available: default, analytics). Using default."
@@ -136,20 +177,7 @@ if ($UsePlanDir) {
136
177
  $Counter++
137
178
  }
138
179
  $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
- }
180
+ New-Item -ItemType Directory -Path $TargetDir -Force -ErrorAction Stop | Out-Null
153
181
  $Mode = Get-InheritedMode $Mode
154
182
  } else {
155
183
  $TargetDir = (Get-Location).Path
@@ -173,11 +201,12 @@ if ($UsePlanDir) {
173
201
  Write-Host "PLAN_ID=$PlanId"
174
202
  }
175
203
 
204
+ try {
176
205
  # Create task_plan.md if it doesn't exist
177
206
  if (-not (Test-Path -LiteralPath $TaskPlanPath)) {
178
207
  $AnalyticsPlan = Join-Path $TemplateDir "analytics_task_plan.md"
179
208
  if ($Template -eq "analytics" -and (Test-Path $AnalyticsPlan)) {
180
- Copy-Item -LiteralPath $AnalyticsPlan -Destination $TaskPlanPath
209
+ Copy-Item -LiteralPath $AnalyticsPlan -Destination $TaskPlanPath -ErrorAction Stop
181
210
  } else {
182
211
  @"
183
212
  # Task Plan: [Brief Description]
@@ -226,7 +255,7 @@ Phase 1
226
255
  ## Errors Encountered
227
256
  | Error | Resolution |
228
257
  |-------|------------|
229
- "@ | Out-File -LiteralPath $TaskPlanPath -Encoding UTF8
258
+ "@ | Out-File -LiteralPath $TaskPlanPath -Encoding UTF8 -ErrorAction Stop
230
259
  }
231
260
  Write-Host "Created $TaskPlanDisplay"
232
261
  } else {
@@ -237,7 +266,7 @@ Phase 1
237
266
  if (-not (Test-Path -LiteralPath $FindingsPath)) {
238
267
  $AnalyticsFindings = Join-Path $TemplateDir "analytics_findings.md"
239
268
  if ($Template -eq "analytics" -and (Test-Path $AnalyticsFindings)) {
240
- Copy-Item -LiteralPath $AnalyticsFindings -Destination $FindingsPath
269
+ Copy-Item -LiteralPath $AnalyticsFindings -Destination $FindingsPath -ErrorAction Stop
241
270
  } else {
242
271
  @"
243
272
  # Findings & Decisions
@@ -258,7 +287,7 @@ if (-not (Test-Path -LiteralPath $FindingsPath)) {
258
287
 
259
288
  ## Resources
260
289
  -
261
- "@ | Out-File -LiteralPath $FindingsPath -Encoding UTF8
290
+ "@ | Out-File -LiteralPath $FindingsPath -Encoding UTF8 -ErrorAction Stop
262
291
  }
263
292
  Write-Host "Created $FindingsDisplay"
264
293
  } else {
@@ -287,7 +316,7 @@ if (-not (Test-Path -LiteralPath $ProgressPath)) {
287
316
  ### Errors
288
317
  | Error | Resolution |
289
318
  |-------|------------|
290
- "@ | Out-File -LiteralPath $ProgressPath -Encoding UTF8
319
+ "@ | Out-File -LiteralPath $ProgressPath -Encoding UTF8 -ErrorAction Stop
291
320
  } else {
292
321
  @"
293
322
  # Progress Log
@@ -308,12 +337,56 @@ if (-not (Test-Path -LiteralPath $ProgressPath)) {
308
337
  ### Errors
309
338
  | Error | Resolution |
310
339
  |-------|------------|
311
- "@ | Out-File -LiteralPath $ProgressPath -Encoding UTF8
340
+ "@ | Out-File -LiteralPath $ProgressPath -Encoding UTF8 -ErrorAction Stop
312
341
  }
313
342
  Write-Host "Created $ProgressDisplay"
314
343
  } else {
315
344
  Write-Host "$ProgressDisplay already exists, skipping"
316
345
  }
346
+ } catch {
347
+ Write-Error "Error: could not initialize planning files in '$TargetDir': $($_.Exception.Message)"
348
+ exit 1
349
+ }
350
+
351
+ if ($UsePlanDir) {
352
+ # Activate the named plan only after all three planning files are ready.
353
+ # This matches init-session.sh and prevents a failed initialization from
354
+ # leaving .active_plan pointed at a partial plan directory.
355
+ $pointerSet = $false
356
+ for ($attempt = 1; $attempt -le 5; $attempt++) {
357
+ $global:LASTEXITCODE = 0
358
+ try {
359
+ $selectorResult = & $PlanSelector $PlanId 2>&1
360
+ } catch {
361
+ $selectorResult = $_
362
+ $global:LASTEXITCODE = 1
363
+ }
364
+ if ($LASTEXITCODE -eq 0) {
365
+ $pointerSet = $true
366
+ break
367
+ }
368
+ # Another writer can replace the pointer between the selector's
369
+ # Get-Item and final-path check. Retry only that transient result.
370
+ $transientPointerRace = $selectorResult -is [System.Management.Automation.ErrorRecord] -and
371
+ $selectorResult.Exception.Message -ceq
372
+ 'Error: could not set the active plan pointer: the active plan pointer became unsafe during replacement'
373
+ if ($attempt -eq 5 -or -not $transientPointerRace) {
374
+ break
375
+ }
376
+ Start-Sleep -Milliseconds 50
377
+ $global:LASTEXITCODE = 0
378
+ try {
379
+ & $PlanSelector -VerifyRoot *> $null
380
+ } catch {
381
+ $global:LASTEXITCODE = 1
382
+ }
383
+ if ($LASTEXITCODE -ne 0) { break }
384
+ }
385
+ if (-not $pointerSet) {
386
+ Write-Error "Error: could not safely update the active plan pointer at $(Join-Path $PlanningRoot '.active_plan')."
387
+ exit 1
388
+ }
389
+ }
317
390
 
318
391
  Write-Host ""
319
392
  Write-Host "Planning files initialized!"
@@ -350,42 +423,94 @@ if ($Mode -ne "") {
350
423
  # (c) auto-attest (attestation default-on in v3 modes, security strand rec 1).
351
424
  # attest-plan.ps1 intentionally refuses non-Windows hosts because its secure
352
425
  # 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.
426
+ # attester instead. Slug mode binds PWF_PLAN_ROOT and PLAN_ID to the plan
427
+ # we just created so an inherited pin or slug cannot redirect attestation
428
+ # to another project or plan (#261, #237). Root mode clears both instead:
429
+ # the attester only falls back to the legacy ./task_plan.md when no
430
+ # selector is set, and a bound pin would make it refuse the root plan.
431
+ $AttestationSucceeded = $false
432
+ $AttestationCommand = "attest-plan"
433
+ $AttestationReason = "task_plan.md was not available for attestation"
355
434
  $PlanFilePwf = Join-Path $PlanDirPwf "task_plan.md"
356
- if (Test-Path -LiteralPath $PlanFilePwf) {
435
+ if (Test-Path -LiteralPath $PlanFilePwf -PathType Leaf) {
357
436
  $HadPlanId = Test-Path Env:PLAN_ID
358
437
  $PreviousPlanId = $env:PLAN_ID
438
+ $HadPlanRoot = Test-Path Env:PWF_PLAN_ROOT
439
+ $PreviousPlanRoot = $env:PWF_PLAN_ROOT
359
440
  try {
360
441
  if ($UsePlanDir) {
442
+ $env:PWF_PLAN_ROOT = (Get-Location).Path
361
443
  $env:PLAN_ID = $PlanId
362
444
  } else {
445
+ Remove-Item Env:PWF_PLAN_ROOT -ErrorAction SilentlyContinue
363
446
  Remove-Item Env:PLAN_ID -ErrorAction SilentlyContinue
364
447
  }
365
448
 
449
+ # A called script can return without changing $LASTEXITCODE, so
450
+ # clear the inherited value before every attempt. Both a non-zero
451
+ # status and a terminating exception mean the plan is not attested.
452
+ $global:LASTEXITCODE = 0
453
+ $AttestOutput = @()
366
454
  $IsWindowsHost = [Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT
367
455
  if ($IsWindowsHost) {
368
- $AttestPs1 = Join-Path $ScriptDir "attest-plan.ps1"
369
- if (Test-Path -LiteralPath $AttestPs1) {
370
- & $AttestPs1 *> $null
456
+ $AttestationCommand = "attest-plan.ps1"
457
+ $AttestPs1 = Join-Path $ScriptDir $AttestationCommand
458
+ if (Test-Path -LiteralPath $AttestPs1 -PathType Leaf) {
459
+ $AttestOutput = @(& $AttestPs1 2>&1)
460
+ $AttestExitCode = $LASTEXITCODE
461
+ if ($AttestExitCode -eq 0) {
462
+ $AttestationSucceeded = $true
463
+ $AttestationReason = ""
464
+ } else {
465
+ $AttestationReason = Format-AttestationFailureReason `
466
+ -Output $AttestOutput `
467
+ -Fallback "$AttestationCommand exited with code $AttestExitCode"
468
+ }
469
+ } else {
470
+ $AttestationReason = "$AttestationCommand was not found beside init-session.ps1"
371
471
  }
372
472
  } else {
373
- $AttestSh = Join-Path $ScriptDir "attest-plan.sh"
473
+ $AttestationCommand = "attest-plan.sh"
474
+ $AttestSh = Join-Path $ScriptDir $AttestationCommand
374
475
  $Sh = Get-Command sh -ErrorAction SilentlyContinue
375
- if ($Sh -and (Test-Path -LiteralPath $AttestSh)) {
376
- & $Sh.Path $AttestSh *> $null
476
+ if ($Sh -and (Test-Path -LiteralPath $AttestSh -PathType Leaf)) {
477
+ $AttestOutput = @(& $Sh.Path $AttestSh 2>&1)
478
+ $AttestExitCode = $LASTEXITCODE
479
+ if ($AttestExitCode -eq 0) {
480
+ $AttestationSucceeded = $true
481
+ $AttestationReason = ""
482
+ } else {
483
+ $AttestationReason = Format-AttestationFailureReason `
484
+ -Output $AttestOutput `
485
+ -Fallback "$AttestationCommand exited with code $AttestExitCode"
486
+ }
487
+ } elseif (-not $Sh) {
488
+ $AttestationReason = "sh was not found; $AttestationCommand could not run"
489
+ } else {
490
+ $AttestationReason = "$AttestationCommand was not found beside init-session.ps1"
377
491
  }
378
492
  }
379
493
  } catch {
380
- # attestation failure must not abort init; the mode marker still stands.
494
+ $AttestationReason = Format-AttestationFailureReason `
495
+ -Output @($_) `
496
+ -Fallback "$AttestationCommand failed"
381
497
  } finally {
382
498
  if ($HadPlanId) {
383
499
  $env:PLAN_ID = $PreviousPlanId
384
500
  } else {
385
501
  Remove-Item Env:PLAN_ID -ErrorAction SilentlyContinue
386
502
  }
503
+ if ($HadPlanRoot) {
504
+ $env:PWF_PLAN_ROOT = $PreviousPlanRoot
505
+ } else {
506
+ Remove-Item Env:PWF_PLAN_ROOT -ErrorAction SilentlyContinue
507
+ }
387
508
  }
388
509
  }
389
510
 
390
- Write-Host "Mode: $MarkerText (attested, gate counter reset)"
511
+ if ($AttestationSucceeded) {
512
+ Write-Host "Mode: $MarkerText (attested, gate counter reset)"
513
+ } else {
514
+ Write-Host "Mode: $MarkerText (NOT attested: $AttestationReason; run $AttestationCommand before the first hook fire)"
515
+ }
391
516
  }
@@ -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 }