planning-with-files 3.19.0 → 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/package.json +1 -1
- package/scripts/attest-plan.ps1 +10 -2
- package/scripts/init-session.ps1 +111 -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 +1 -1
- 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 +13 -2
- package/scripts/set-active-plan.sh +8 -1
- package/templates/analytics_task_plan.md +87 -81
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "planning-with-files",
|
|
3
|
-
"version": "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",
|
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
|
@@ -80,6 +80,26 @@ function Get-InheritedMode([string]$CurrentMode) {
|
|
|
80
80
|
return $CurrentMode
|
|
81
81
|
}
|
|
82
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
|
+
}
|
|
102
|
+
|
|
83
103
|
# Validate template
|
|
84
104
|
if ($Template -ne "default" -and $Template -ne "analytics") {
|
|
85
105
|
Write-Host "Unknown template: $Template (available: default, analytics). Using default."
|
|
@@ -136,20 +156,7 @@ if ($UsePlanDir) {
|
|
|
136
156
|
$Counter++
|
|
137
157
|
}
|
|
138
158
|
$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
|
-
}
|
|
159
|
+
New-Item -ItemType Directory -Path $TargetDir -Force -ErrorAction Stop | Out-Null
|
|
153
160
|
$Mode = Get-InheritedMode $Mode
|
|
154
161
|
} else {
|
|
155
162
|
$TargetDir = (Get-Location).Path
|
|
@@ -173,11 +180,12 @@ if ($UsePlanDir) {
|
|
|
173
180
|
Write-Host "PLAN_ID=$PlanId"
|
|
174
181
|
}
|
|
175
182
|
|
|
183
|
+
try {
|
|
176
184
|
# Create task_plan.md if it doesn't exist
|
|
177
185
|
if (-not (Test-Path -LiteralPath $TaskPlanPath)) {
|
|
178
186
|
$AnalyticsPlan = Join-Path $TemplateDir "analytics_task_plan.md"
|
|
179
187
|
if ($Template -eq "analytics" -and (Test-Path $AnalyticsPlan)) {
|
|
180
|
-
Copy-Item -LiteralPath $AnalyticsPlan -Destination $TaskPlanPath
|
|
188
|
+
Copy-Item -LiteralPath $AnalyticsPlan -Destination $TaskPlanPath -ErrorAction Stop
|
|
181
189
|
} else {
|
|
182
190
|
@"
|
|
183
191
|
# Task Plan: [Brief Description]
|
|
@@ -226,7 +234,7 @@ Phase 1
|
|
|
226
234
|
## Errors Encountered
|
|
227
235
|
| Error | Resolution |
|
|
228
236
|
|-------|------------|
|
|
229
|
-
"@ | Out-File -LiteralPath $TaskPlanPath -Encoding UTF8
|
|
237
|
+
"@ | Out-File -LiteralPath $TaskPlanPath -Encoding UTF8 -ErrorAction Stop
|
|
230
238
|
}
|
|
231
239
|
Write-Host "Created $TaskPlanDisplay"
|
|
232
240
|
} else {
|
|
@@ -237,7 +245,7 @@ Phase 1
|
|
|
237
245
|
if (-not (Test-Path -LiteralPath $FindingsPath)) {
|
|
238
246
|
$AnalyticsFindings = Join-Path $TemplateDir "analytics_findings.md"
|
|
239
247
|
if ($Template -eq "analytics" -and (Test-Path $AnalyticsFindings)) {
|
|
240
|
-
Copy-Item -LiteralPath $AnalyticsFindings -Destination $FindingsPath
|
|
248
|
+
Copy-Item -LiteralPath $AnalyticsFindings -Destination $FindingsPath -ErrorAction Stop
|
|
241
249
|
} else {
|
|
242
250
|
@"
|
|
243
251
|
# Findings & Decisions
|
|
@@ -258,7 +266,7 @@ if (-not (Test-Path -LiteralPath $FindingsPath)) {
|
|
|
258
266
|
|
|
259
267
|
## Resources
|
|
260
268
|
-
|
|
261
|
-
"@ | Out-File -LiteralPath $FindingsPath -Encoding UTF8
|
|
269
|
+
"@ | Out-File -LiteralPath $FindingsPath -Encoding UTF8 -ErrorAction Stop
|
|
262
270
|
}
|
|
263
271
|
Write-Host "Created $FindingsDisplay"
|
|
264
272
|
} else {
|
|
@@ -287,7 +295,7 @@ if (-not (Test-Path -LiteralPath $ProgressPath)) {
|
|
|
287
295
|
### Errors
|
|
288
296
|
| Error | Resolution |
|
|
289
297
|
|-------|------------|
|
|
290
|
-
"@ | Out-File -LiteralPath $ProgressPath -Encoding UTF8
|
|
298
|
+
"@ | Out-File -LiteralPath $ProgressPath -Encoding UTF8 -ErrorAction Stop
|
|
291
299
|
} else {
|
|
292
300
|
@"
|
|
293
301
|
# Progress Log
|
|
@@ -308,12 +316,32 @@ if (-not (Test-Path -LiteralPath $ProgressPath)) {
|
|
|
308
316
|
### Errors
|
|
309
317
|
| Error | Resolution |
|
|
310
318
|
|-------|------------|
|
|
311
|
-
"@ | Out-File -LiteralPath $ProgressPath -Encoding UTF8
|
|
319
|
+
"@ | Out-File -LiteralPath $ProgressPath -Encoding UTF8 -ErrorAction Stop
|
|
312
320
|
}
|
|
313
321
|
Write-Host "Created $ProgressDisplay"
|
|
314
322
|
} else {
|
|
315
323
|
Write-Host "$ProgressDisplay already exists, skipping"
|
|
316
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
|
+
}
|
|
344
|
+
}
|
|
317
345
|
|
|
318
346
|
Write-Host ""
|
|
319
347
|
Write-Host "Planning files initialized!"
|
|
@@ -350,42 +378,94 @@ if ($Mode -ne "") {
|
|
|
350
378
|
# (c) auto-attest (attestation default-on in v3 modes, security strand rec 1).
|
|
351
379
|
# attest-plan.ps1 intentionally refuses non-Windows hosts because its secure
|
|
352
380
|
# no-follow implementation uses Win32 handles. On Unix, use the POSIX
|
|
353
|
-
# attester instead.
|
|
354
|
-
# inherited
|
|
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"
|
|
355
389
|
$PlanFilePwf = Join-Path $PlanDirPwf "task_plan.md"
|
|
356
|
-
if (Test-Path -LiteralPath $PlanFilePwf) {
|
|
390
|
+
if (Test-Path -LiteralPath $PlanFilePwf -PathType Leaf) {
|
|
357
391
|
$HadPlanId = Test-Path Env:PLAN_ID
|
|
358
392
|
$PreviousPlanId = $env:PLAN_ID
|
|
393
|
+
$HadPlanRoot = Test-Path Env:PWF_PLAN_ROOT
|
|
394
|
+
$PreviousPlanRoot = $env:PWF_PLAN_ROOT
|
|
359
395
|
try {
|
|
360
396
|
if ($UsePlanDir) {
|
|
397
|
+
$env:PWF_PLAN_ROOT = (Get-Location).Path
|
|
361
398
|
$env:PLAN_ID = $PlanId
|
|
362
399
|
} else {
|
|
400
|
+
Remove-Item Env:PWF_PLAN_ROOT -ErrorAction SilentlyContinue
|
|
363
401
|
Remove-Item Env:PLAN_ID -ErrorAction SilentlyContinue
|
|
364
402
|
}
|
|
365
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 = @()
|
|
366
409
|
$IsWindowsHost = [Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT
|
|
367
410
|
if ($IsWindowsHost) {
|
|
368
|
-
$
|
|
369
|
-
|
|
370
|
-
|
|
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"
|
|
371
426
|
}
|
|
372
427
|
} else {
|
|
373
|
-
$
|
|
428
|
+
$AttestationCommand = "attest-plan.sh"
|
|
429
|
+
$AttestSh = Join-Path $ScriptDir $AttestationCommand
|
|
374
430
|
$Sh = Get-Command sh -ErrorAction SilentlyContinue
|
|
375
|
-
if ($Sh -and (Test-Path -LiteralPath $AttestSh)) {
|
|
376
|
-
& $Sh.Path $AttestSh
|
|
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"
|
|
377
446
|
}
|
|
378
447
|
}
|
|
379
448
|
} catch {
|
|
380
|
-
|
|
449
|
+
$AttestationReason = Format-AttestationFailureReason `
|
|
450
|
+
-Output @($_) `
|
|
451
|
+
-Fallback "$AttestationCommand failed"
|
|
381
452
|
} finally {
|
|
382
453
|
if ($HadPlanId) {
|
|
383
454
|
$env:PLAN_ID = $PreviousPlanId
|
|
384
455
|
} else {
|
|
385
456
|
Remove-Item Env:PLAN_ID -ErrorAction SilentlyContinue
|
|
386
457
|
}
|
|
458
|
+
if ($HadPlanRoot) {
|
|
459
|
+
$env:PWF_PLAN_ROOT = $PreviousPlanRoot
|
|
460
|
+
} else {
|
|
461
|
+
Remove-Item Env:PWF_PLAN_ROOT -ErrorAction SilentlyContinue
|
|
462
|
+
}
|
|
387
463
|
}
|
|
388
464
|
}
|
|
389
465
|
|
|
390
|
-
|
|
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
|
+
}
|
|
391
471
|
}
|
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 }
|
package/scripts/phase-status.ps1
CHANGED
|
@@ -56,7 +56,7 @@ function Resolve-PlanFile {
|
|
|
56
56
|
|
|
57
57
|
$activePointer = Join-Path $planRoot ".active_plan"
|
|
58
58
|
if (Test-Path -LiteralPath $activePointer) {
|
|
59
|
-
$planId = (Get-Content -LiteralPath $activePointer -Raw).Trim()
|
|
59
|
+
$planId = "$(Get-Content -LiteralPath $activePointer -Raw -ErrorAction SilentlyContinue)".Trim()
|
|
60
60
|
if ($planId) {
|
|
61
61
|
$candidate = Join-Path $planRoot $planId
|
|
62
62
|
$planFile = Join-Path $candidate "task_plan.md"
|
|
@@ -77,6 +77,17 @@ function Get-FinalDirectoryPath {
|
|
|
77
77
|
return (Resolve-Path -LiteralPath $Path -ErrorAction Stop).Path
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
function Test-FullyQualifiedLocalPath {
|
|
81
|
+
param([string]$Path)
|
|
82
|
+
if (-not $Path -or $Path.StartsWith('\\') -or $Path.StartsWith('//')) {
|
|
83
|
+
return $false
|
|
84
|
+
}
|
|
85
|
+
if ($script:IsWindowsHost) {
|
|
86
|
+
return $Path -match '^[A-Za-z]:[\\/]'
|
|
87
|
+
}
|
|
88
|
+
return [System.IO.Path]::IsPathRooted($Path)
|
|
89
|
+
}
|
|
90
|
+
|
|
80
91
|
# PWF_PLAN_ROOT: absolute plan-root binding (issue #212), mirroring
|
|
81
92
|
# resolve-plan-dir.sh. A thread whose cwd is a shared PARENT of the real
|
|
82
93
|
# project resolves the parent's plan and never sees the nested one;
|
|
@@ -90,9 +101,8 @@ function Get-FinalDirectoryPath {
|
|
|
90
101
|
# checked against the pinned root. Unset keeps legacy behavior unchanged.
|
|
91
102
|
if ($env:PWF_PLAN_ROOT) {
|
|
92
103
|
$pin = $env:PWF_PLAN_ROOT
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
if ($isAbsolute -and -not $isUnc -and (Test-Path -LiteralPath $pin -PathType Container)) {
|
|
104
|
+
if ((Test-FullyQualifiedLocalPath $pin) -and
|
|
105
|
+
(Test-Path -LiteralPath $pin -PathType Container)) {
|
|
96
106
|
$projectRoot = $pin
|
|
97
107
|
$PlanRoot = Join-Path $pin ".planning"
|
|
98
108
|
} else {
|
|
@@ -129,6 +139,24 @@ function Test-WithinRoot {
|
|
|
129
139
|
return $candNorm.StartsWith($rootNorm + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase)
|
|
130
140
|
}
|
|
131
141
|
|
|
142
|
+
# A linked plan directory (symlink or junction) is never selectable: not by
|
|
143
|
+
# PLAN_ID, not by the pointer, not by the newest scan, and it never counts
|
|
144
|
+
# (#270). This is `[ -L ]` of resolve-plan-dir.sh and is_link of the Python
|
|
145
|
+
# twin: LinkType names symlinks and junctions only. The ReparsePoint
|
|
146
|
+
# attribute alone would also match OneDrive Files On-Demand placeholders,
|
|
147
|
+
# which every synced directory and file carries and which are not links to
|
|
148
|
+
# sh. The same predicate guards the pointer file below (#275).
|
|
149
|
+
function Test-LinkedDirectory {
|
|
150
|
+
param($PathOrItem)
|
|
151
|
+
if ($PathOrItem -is [string]) {
|
|
152
|
+
$item = Get-Item -LiteralPath $PathOrItem -Force -ErrorAction SilentlyContinue
|
|
153
|
+
} else {
|
|
154
|
+
$item = $PathOrItem
|
|
155
|
+
}
|
|
156
|
+
if (-not $item) { return $false }
|
|
157
|
+
return ([string]$item.LinkType) -in @('SymbolicLink', 'Junction')
|
|
158
|
+
}
|
|
159
|
+
|
|
132
160
|
$activeFile = Join-Path $PlanRoot ".active_plan"
|
|
133
161
|
|
|
134
162
|
# A set PLAN_ID is a BINDING, not a hint (issue #237). A selector that names
|
|
@@ -148,6 +176,9 @@ if (-not $env:PLAN_ID) {
|
|
|
148
176
|
}
|
|
149
177
|
if (Test-Path -LiteralPath $PlanRoot -PathType Container) {
|
|
150
178
|
foreach ($entry in (Get-ChildItem -LiteralPath $PlanRoot -Directory -ErrorAction SilentlyContinue)) {
|
|
179
|
+
# A linked plan directory (symlink, junction) is not selectable and
|
|
180
|
+
# never counts, matching `[ -L ]` in resolve-plan-dir.sh (#270).
|
|
181
|
+
if (Test-LinkedDirectory $entry) { continue }
|
|
151
182
|
if ((Test-ValidSlug $entry.Name) -and
|
|
152
183
|
(Test-Path -LiteralPath (Join-Path $entry.FullName "task_plan.md") -PathType Leaf)) {
|
|
153
184
|
$planCount++
|
|
@@ -165,7 +196,7 @@ if ($planCount -gt 1) { exit 0 }
|
|
|
165
196
|
if ($env:PLAN_ID) {
|
|
166
197
|
if (Test-ValidSlug $env:PLAN_ID) {
|
|
167
198
|
$candidate = Join-Path $PlanRoot $env:PLAN_ID
|
|
168
|
-
if ((Test-Path $candidate -PathType Container) -and (Test-WithinRoot $candidate)) {
|
|
199
|
+
if ((Test-Path -LiteralPath $candidate -PathType Container) -and -not (Test-LinkedDirectory $candidate) -and (Test-WithinRoot $candidate)) {
|
|
169
200
|
Write-Output $candidate
|
|
170
201
|
exit 0
|
|
171
202
|
}
|
|
@@ -175,29 +206,37 @@ if ($env:PLAN_ID) {
|
|
|
175
206
|
|
|
176
207
|
# Get-Item observes the link object even when its target is missing, unlike
|
|
177
208
|
# Test-Path which follows the target. An active pointer that is a directory or
|
|
178
|
-
#
|
|
209
|
+
# a symlink is an unsafe/ambiguous selector and must terminate resolution;
|
|
179
210
|
# falling through would silently select and expose the newest unrelated plan.
|
|
211
|
+
# LinkType, not the ReparsePoint attribute: OneDrive Files On-Demand marks
|
|
212
|
+
# every synced file as a reparse point, and such a pointer is a plain file
|
|
213
|
+
# to every other route (#275).
|
|
180
214
|
$activeItem = Get-Item -LiteralPath $activeFile -Force -ErrorAction SilentlyContinue
|
|
181
215
|
if ($activeItem) {
|
|
182
|
-
if ($activeItem.PSIsContainer -or
|
|
183
|
-
(($activeItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)) {
|
|
216
|
+
if ($activeItem.PSIsContainer -or (Test-LinkedDirectory $activeItem)) {
|
|
184
217
|
exit 0
|
|
185
218
|
}
|
|
186
|
-
|
|
219
|
+
# Get-Content -Raw returns $null for a zero-byte pointer; an empty pointer
|
|
220
|
+
# falls through like an invalid one instead of raising inside a caller.
|
|
221
|
+
$planId = "$(Get-Content -LiteralPath $activeFile -Raw -ErrorAction SilentlyContinue)".Trim()
|
|
187
222
|
if ($planId -and (Test-ValidSlug $planId)) {
|
|
188
223
|
$candidate = Join-Path $PlanRoot $planId
|
|
189
|
-
if ((Test-Path $candidate -PathType Container) -and (Test-WithinRoot $candidate)) {
|
|
224
|
+
if ((Test-Path -LiteralPath $candidate -PathType Container) -and -not (Test-LinkedDirectory $candidate) -and (Test-WithinRoot $candidate)) {
|
|
190
225
|
Write-Output $candidate
|
|
191
226
|
exit 0
|
|
192
227
|
}
|
|
193
228
|
}
|
|
194
229
|
}
|
|
195
230
|
|
|
196
|
-
|
|
197
|
-
|
|
231
|
+
# Literal paths throughout: a project path containing [ or ] is a wildcard to
|
|
232
|
+
# Test-Path and Get-ChildItem -Path, and a pattern that matches nothing turned a
|
|
233
|
+
# valid selection into an empty result.
|
|
234
|
+
if (Test-Path -LiteralPath $PlanRoot -PathType Container) {
|
|
235
|
+
$latest = Get-ChildItem -LiteralPath $PlanRoot -Directory |
|
|
198
236
|
Where-Object { -not $_.Name.StartsWith('.') } |
|
|
237
|
+
Where-Object { -not (Test-LinkedDirectory $_) } |
|
|
199
238
|
Where-Object { Test-ValidSlug $_.Name } |
|
|
200
|
-
Where-Object { Test-Path (Join-Path $_.FullName "task_plan.md") -PathType Leaf } |
|
|
239
|
+
Where-Object { Test-Path -LiteralPath (Join-Path $_.FullName "task_plan.md") -PathType Leaf } |
|
|
201
240
|
Where-Object { Test-WithinRoot $_.FullName } |
|
|
202
241
|
Sort-Object LastWriteTime -Descending |
|
|
203
242
|
Select-Object -First 1
|
|
@@ -246,10 +246,16 @@ mtime_of() {
|
|
|
246
246
|
printf "0\n"
|
|
247
247
|
}
|
|
248
248
|
|
|
249
|
+
# A linked plan directory (symlink or junction; `-L` sees both under Git
|
|
250
|
+
# Bash) is never selectable: not by PLAN_ID, not by the pointer, not by the
|
|
251
|
+
# newest-mtime scan, and it never counts below (#270). Containment alone let
|
|
252
|
+
# a link that stays inside the root be selected while the counter skipped
|
|
253
|
+
# it, so one real plan plus a newer linked one became an mtime guess again.
|
|
249
254
|
resolve_from_env() {
|
|
250
255
|
plan_id="${PLAN_ID:-}"
|
|
251
256
|
slug_is_valid "${plan_id}" || return 1
|
|
252
257
|
candidate="${PLAN_ROOT}/${plan_id}"
|
|
258
|
+
[ -L "${candidate}" ] && return 1
|
|
253
259
|
if [ -d "${candidate}" ] && is_within_root "${candidate}"; then
|
|
254
260
|
printf "%s\n" "${candidate}"
|
|
255
261
|
return 0
|
|
@@ -268,6 +274,7 @@ resolve_from_active_file() {
|
|
|
268
274
|
esac
|
|
269
275
|
slug_is_valid "${plan_id}" || return 1
|
|
270
276
|
candidate="${PLAN_ROOT}/${plan_id}"
|
|
277
|
+
[ -L "${candidate}" ] && return 1
|
|
271
278
|
if [ -d "${candidate}" ] && is_within_root "${candidate}"; then
|
|
272
279
|
printf "%s\n" "${candidate}"
|
|
273
280
|
return 0
|
|
@@ -288,6 +295,7 @@ resolve_latest_dir() {
|
|
|
288
295
|
case "${name}" in
|
|
289
296
|
.*) continue ;;
|
|
290
297
|
esac
|
|
298
|
+
[ -L "${clean}" ] && continue
|
|
291
299
|
slug_is_valid "${name}" || continue
|
|
292
300
|
[ -f "${clean}/task_plan.md" ] || continue
|
|
293
301
|
is_within_root "${clean}" || continue
|
|
@@ -338,8 +346,9 @@ if [ -z "${PLAN_ID:-}" ]; then
|
|
|
338
346
|
PLAN_COUNT=1
|
|
339
347
|
fi
|
|
340
348
|
for plan_candidate in "${PLAN_ROOT}"/*/task_plan.md; do
|
|
341
|
-
[ -f "$plan_candidate" ] || continue
|
|
342
349
|
plan_candidate_dir="${plan_candidate%/task_plan.md}"
|
|
350
|
+
[ -L "$plan_candidate_dir" ] && continue
|
|
351
|
+
[ -f "$plan_candidate" ] || continue
|
|
343
352
|
slug_is_valid "${plan_candidate_dir##*/}" || continue
|
|
344
353
|
PLAN_COUNT=$((PLAN_COUNT + 1))
|
|
345
354
|
if [ "$PLAN_COUNT" -gt 1 ]; then PLAN_AMBIGUOUS=1; break; fi
|
|
@@ -468,13 +468,16 @@ def _opencode_state_annotation(state: Any) -> str:
|
|
|
468
468
|
|
|
469
469
|
def _format_opencode_part(data: Dict[str, Any], session_id: str) -> Optional[Dict[str, Any]]:
|
|
470
470
|
"""Print-ready summary for one OpenCode part row."""
|
|
471
|
+
if not isinstance(data, dict):
|
|
472
|
+
return None
|
|
471
473
|
ptype = data.get('type')
|
|
472
474
|
short = safe_session_label(session_id)
|
|
473
475
|
if ptype == 'tool':
|
|
474
|
-
|
|
476
|
+
tool_value = data.get('tool')
|
|
477
|
+
tool = tool_value.lower() if isinstance(tool_value, str) else ''
|
|
475
478
|
state = data.get('state') or {}
|
|
476
479
|
input_ = state.get('input') if isinstance(state, dict) else None
|
|
477
|
-
input_ = input_
|
|
480
|
+
input_ = input_ if isinstance(input_, dict) else {}
|
|
478
481
|
outcome = _opencode_state_annotation(state)
|
|
479
482
|
if tool in ('write', 'edit'):
|
|
480
483
|
fp = input_.get('filePath', '')
|
|
@@ -486,7 +489,8 @@ def _format_opencode_part(data: Dict[str, Any], session_id: str) -> Optional[Dic
|
|
|
486
489
|
return {'session': short, 'summary': f"Tool bash: {cmd}{outcome}"}
|
|
487
490
|
return {'session': short, 'summary': f"Tool {tool}{outcome}"}
|
|
488
491
|
if ptype == 'text':
|
|
489
|
-
|
|
492
|
+
text_value = data.get('text')
|
|
493
|
+
text = text_value[:300] if isinstance(text_value, str) else ''
|
|
490
494
|
if text.strip():
|
|
491
495
|
return {'session': short, 'summary': f"text: {text}"}
|
|
492
496
|
return None
|
|
@@ -577,6 +581,7 @@ def opencode_catchup(project_path: str, mode: str = 'no-history') -> None:
|
|
|
577
581
|
"""
|
|
578
582
|
SELECT time_created, data FROM part
|
|
579
583
|
WHERE session_id = ?
|
|
584
|
+
AND json_valid(data)
|
|
580
585
|
AND json_extract(data, '$.type') = 'tool'
|
|
581
586
|
AND lower(json_extract(data, '$.tool')) IN ('write', 'edit', 'patch')
|
|
582
587
|
AND (
|
|
@@ -121,9 +121,13 @@ function Test-SafeActiveFile {
|
|
|
121
121
|
param([switch]$AllowLink)
|
|
122
122
|
$item = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
|
|
123
123
|
if (-not $item) { return $false }
|
|
124
|
-
# Reading may follow a verified in-project link; writing must not.
|
|
124
|
+
# Reading may follow a verified in-project link; writing must not. A link
|
|
125
|
+
# is a symlink or junction by LinkType, never the bare ReparsePoint
|
|
126
|
+
# attribute: OneDrive Files On-Demand marks every synced file as a
|
|
127
|
+
# reparse point, and the pointer must stay writable there (#275).
|
|
128
|
+
$linked = ([string]$item.LinkType) -in @('SymbolicLink', 'Junction')
|
|
125
129
|
return -not $item.PSIsContainer -and
|
|
126
|
-
($AllowLink -or
|
|
130
|
+
($AllowLink -or -not $linked) -and
|
|
127
131
|
(Test-WithinRoot $ActiveFile)
|
|
128
132
|
}
|
|
129
133
|
|
|
@@ -204,6 +208,8 @@ function Show-PlanList {
|
|
|
204
208
|
Write-Output "[active] marks the shared .active_plan pointer; listing does not bind this session."
|
|
205
209
|
$found = $false
|
|
206
210
|
foreach ($plan in (Get-ChildItem -LiteralPath $PlanRoot -Directory -ErrorAction SilentlyContinue | Sort-Object Name)) {
|
|
211
|
+
# A linked plan directory is never a plan (#270): no resolver selects it.
|
|
212
|
+
if (([string]$plan.LinkType) -in @('SymbolicLink', 'Junction')) { continue }
|
|
207
213
|
if (-not (Test-ValidSlug $plan.Name) -or -not (Test-WithinRoot $plan.FullName)) { continue }
|
|
208
214
|
$planFile = Join-Path $plan.FullName "task_plan.md"
|
|
209
215
|
if (-not (Test-Path -LiteralPath $planFile -PathType Leaf) -or -not (Test-WithinRoot $planFile)) { continue }
|
|
@@ -281,6 +287,11 @@ if (-not (Test-Path -LiteralPath $PlanDir -PathType Container)) {
|
|
|
281
287
|
Write-Error "Run: init-session.sh `"$PlanId`" to create it, or check .planning\ for available plans."
|
|
282
288
|
exit 1
|
|
283
289
|
}
|
|
290
|
+
$planDirItem = Get-Item -LiteralPath $PlanDir -Force -ErrorAction SilentlyContinue
|
|
291
|
+
if ($planDirItem -and (([string]$planDirItem.LinkType) -in @('SymbolicLink', 'Junction'))) {
|
|
292
|
+
Write-Error "Error: plan directory is a symlink or junction and no route selects it: $PlanDir"
|
|
293
|
+
exit 1
|
|
294
|
+
}
|
|
284
295
|
if (-not (Test-WithinRoot $PlanRoot) -or -not (Test-WithinRoot $PlanDir)) {
|
|
285
296
|
Write-Error "Error: plan directory must remain within the project."
|
|
286
297
|
exit 1
|
|
@@ -272,6 +272,9 @@ list_plans() {
|
|
|
272
272
|
printf '%s\n' 'Available plans:'
|
|
273
273
|
for _dir in "${PLAN_ROOT}"/*; do
|
|
274
274
|
[ -d "${_dir}" ] || continue
|
|
275
|
+
# A linked plan directory is never a plan (#270): no resolver selects
|
|
276
|
+
# it, so listing it would advertise a PLAN_ID every route refuses.
|
|
277
|
+
[ -L "${_dir}" ] && continue
|
|
275
278
|
_id="${_dir##*/}"
|
|
276
279
|
slug_is_valid "${_id}" || continue
|
|
277
280
|
is_within_root "${_dir}" || continue
|
|
@@ -312,7 +315,7 @@ if [ "${1:-}" = '' ]; then
|
|
|
312
315
|
exit 1
|
|
313
316
|
fi
|
|
314
317
|
plan_id="$(current_active)"
|
|
315
|
-
if [ -n "${plan_id}" ] && [ -d "${PLAN_ROOT}/${plan_id}" ] && is_within_root "${PLAN_ROOT}/${plan_id}"; then
|
|
318
|
+
if [ -n "${plan_id}" ] && [ -d "${PLAN_ROOT}/${plan_id}" ] && [ ! -L "${PLAN_ROOT}/${plan_id}" ] && is_within_root "${PLAN_ROOT}/${plan_id}"; then
|
|
316
319
|
printf '%s\n' "Active plan: ${plan_id}" "Path: ${PLAN_ROOT}/${plan_id}"
|
|
317
320
|
elif [ -n "${plan_id}" ]; then
|
|
318
321
|
printf '%s\n' "Active plan pointer: ${plan_id} (directory not found or outside project - stale pointer)"
|
|
@@ -333,6 +336,10 @@ if [ ! -d "${PLAN_DIR}" ]; then
|
|
|
333
336
|
"Run: init-session.sh \"${PLAN_ID}\" to create it, or use --list to see available plans." >&2
|
|
334
337
|
exit 1
|
|
335
338
|
fi
|
|
339
|
+
if [ -L "${PLAN_DIR}" ]; then
|
|
340
|
+
printf '%s\n' "Error: plan directory is a symlink or junction and no route selects it: ${PLAN_DIR}" >&2
|
|
341
|
+
exit 1
|
|
342
|
+
fi
|
|
336
343
|
if ! is_within_root "${PLAN_ROOT}" || ! is_within_root "${PLAN_DIR}"; then
|
|
337
344
|
printf '%s\n' 'Error: plan directory is outside the project or cannot be verified.' >&2
|
|
338
345
|
exit 1
|
|
@@ -1,81 +1,87 @@
|
|
|
1
|
-
# Task Plan: [Analytics Project Description]
|
|
2
|
-
|
|
3
|
-
Use this file as the durable roadmap for a data analytics or exploration session. Keep phase status current as the analysis advances.
|
|
4
|
-
|
|
5
|
-
## Goal
|
|
6
|
-
|
|
7
|
-
State the analytical question or intended deliverable in one clear sentence.
|
|
8
|
-
|
|
9
|
-
[One sentence describing the analytical objective]
|
|
10
|
-
|
|
11
|
-
##
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
##
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
- [ ]
|
|
32
|
-
- [ ]
|
|
33
|
-
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
- [ ]
|
|
40
|
-
- [ ]
|
|
41
|
-
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
- [ ]
|
|
48
|
-
- [ ]
|
|
49
|
-
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
1
|
+
# Task Plan: [Analytics Project Description]
|
|
2
|
+
|
|
3
|
+
Use this file as the durable roadmap for a data analytics or exploration session. Keep phase status current as the analysis advances.
|
|
4
|
+
|
|
5
|
+
## Goal
|
|
6
|
+
|
|
7
|
+
State the analytical question or intended deliverable in one clear sentence.
|
|
8
|
+
|
|
9
|
+
[One sentence describing the analytical objective]
|
|
10
|
+
|
|
11
|
+
## Next Step
|
|
12
|
+
|
|
13
|
+
Record the single analytical action that should happen next. Update it whenever the active phase or immediate action changes.
|
|
14
|
+
|
|
15
|
+
[The single next analytical action. Update whenever phase status changes.]
|
|
16
|
+
|
|
17
|
+
## Current Phase
|
|
18
|
+
|
|
19
|
+
Name the phase currently being worked on.
|
|
20
|
+
|
|
21
|
+
Phase 1
|
|
22
|
+
|
|
23
|
+
## Phases
|
|
24
|
+
|
|
25
|
+
Use only `pending`, `in_progress`, or `complete` for each status.
|
|
26
|
+
|
|
27
|
+
### Phase 1: Data Discovery
|
|
28
|
+
|
|
29
|
+
- [ ] Identify and connect to data sources
|
|
30
|
+
- [ ] Document schemas and field descriptions in findings.md
|
|
31
|
+
- [ ] Assess data quality (nulls, duplicates, outliers, date ranges)
|
|
32
|
+
- [ ] Estimate dataset size and query performance
|
|
33
|
+
- **Status:** in_progress
|
|
34
|
+
|
|
35
|
+
### Phase 2: Exploratory Analysis
|
|
36
|
+
|
|
37
|
+
- [ ] Compute summary statistics for key variables
|
|
38
|
+
- [ ] Visualize distributions and relationships
|
|
39
|
+
- [ ] Identify outliers and anomalies
|
|
40
|
+
- [ ] Document initial patterns in findings.md
|
|
41
|
+
- **Status:** pending
|
|
42
|
+
|
|
43
|
+
### Phase 3: Hypothesis Testing
|
|
44
|
+
|
|
45
|
+
- [ ] Formalize hypotheses from exploratory phase
|
|
46
|
+
- [ ] Select appropriate statistical tests
|
|
47
|
+
- [ ] Run tests and record results in findings.md
|
|
48
|
+
- [ ] Validate findings against holdout data or alternative methods
|
|
49
|
+
- **Status:** pending
|
|
50
|
+
|
|
51
|
+
### Phase 4: Synthesis & Reporting
|
|
52
|
+
|
|
53
|
+
- [ ] Summarize key findings with supporting evidence
|
|
54
|
+
- [ ] Create final visualizations
|
|
55
|
+
- [ ] Document conclusions and recommendations
|
|
56
|
+
- [ ] Note limitations and areas for further investigation
|
|
57
|
+
- **Status:** pending
|
|
58
|
+
|
|
59
|
+
## Hypotheses
|
|
60
|
+
|
|
61
|
+
Record the questions under investigation as testable hypotheses.
|
|
62
|
+
|
|
63
|
+
1. [Hypothesis to test]
|
|
64
|
+
2. [Hypothesis to test]
|
|
65
|
+
|
|
66
|
+
## Decisions Made
|
|
67
|
+
|
|
68
|
+
Record analytical choices, including tests, filters, exclusions, and their rationale.
|
|
69
|
+
|
|
70
|
+
| Decision | Rationale |
|
|
71
|
+
|----------|-----------|
|
|
72
|
+
| | |
|
|
73
|
+
|
|
74
|
+
## Errors Encountered
|
|
75
|
+
|
|
76
|
+
Record each distinct error, the attempt number, and the resolution. Change the approach before retrying a failed action.
|
|
77
|
+
|
|
78
|
+
| Error | Attempt | Resolution |
|
|
79
|
+
|-------|---------|------------|
|
|
80
|
+
| | 1 | |
|
|
81
|
+
|
|
82
|
+
## Notes
|
|
83
|
+
|
|
84
|
+
- Update phase status as work progresses: `pending` to `in_progress` to `complete`.
|
|
85
|
+
- Re-read the goal, next step, and current phase before major analytical decisions.
|
|
86
|
+
- Log errors promptly so failed approaches are not repeated.
|
|
87
|
+
- Record query results and visual evidence in findings.md.
|