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/README.md +852 -127
- package/SKILL.md +11 -9
- package/package.json +5 -5
- package/scripts/attest-plan.ps1 +10 -2
- package/scripts/init-session.ps1 +156 -31
- package/scripts/init-session.sh +62 -8
- package/scripts/inject-plan.py +17 -5
- package/scripts/inject-plan.sh +7 -3
- package/scripts/ledger-append.ps1 +1 -1
- package/scripts/ledger-summary.ps1 +1 -1
- package/scripts/phase-status.ps1 +39 -13
- package/scripts/phase-status.sh +29 -11
- package/scripts/resolve-plan-dir.ps1 +51 -12
- package/scripts/resolve-plan-dir.sh +10 -1
- package/scripts/session-catchup.py +8 -3
- package/scripts/set-active-plan.ps1 +31 -6
- package/scripts/set-active-plan.sh +8 -1
- package/templates/analytics_findings.md +67 -67
- package/templates/analytics_task_plan.md +87 -81
- package/templates/findings.md +47 -47
- package/templates/progress.md +58 -58
- package/templates/task_plan.md +89 -89
package/SKILL.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
name:
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
4
|
-
"description": "
|
|
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
|
-
"
|
|
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": [
|
package/scripts/attest-plan.ps1
CHANGED
|
@@ -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
|
-
|
|
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 (-
|
|
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
|
package/scripts/init-session.ps1
CHANGED
|
@@ -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.
|
|
354
|
-
# inherited
|
|
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
|
-
$
|
|
369
|
-
|
|
370
|
-
|
|
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
|
-
$
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/scripts/init-session.sh
CHANGED
|
@@ -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
|
|
206
|
-
#
|
|
207
|
-
#
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
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
|
-
|
|
490
|
+
print_v3_mode_status "$(pwd)"
|
|
437
491
|
fi
|
|
438
492
|
fi
|
package/scripts/inject-plan.py
CHANGED
|
@@ -845,7 +845,10 @@ class Injector(object):
|
|
|
845
845
|
)
|
|
846
846
|
raise Bail()
|
|
847
847
|
if plan_id:
|
|
848
|
-
|
|
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
|
-
|
|
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"):
|
package/scripts/inject-plan.sh
CHANGED
|
@@ -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
|
-
|
|
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 }
|