planning-with-files 3.17.1 → 3.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/SKILL.md +504 -498
- package/package.json +1 -1
- package/scripts/check-complete.ps1 +4 -2
- package/scripts/check-complete.sh +6 -3
- package/scripts/init-session.ps1 +185 -24
- package/scripts/init-session.sh +23 -2
- package/scripts/session-catchup.py +36 -20
- package/scripts/set-active-plan.ps1 +290 -16
- package/scripts/set-active-plan.sh +347 -34
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "planning-with-files",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.19.0",
|
|
4
4
|
"description": "Persistent project planning with selected context injection. Automatic recovery uses project files only; explicit catchup modes read same-project local session records for aggregate counts or bounded replay. The host-aware gate never runs Markdown-declared commands. No network upload path. Ships the skill plus a Pi Coding Agent extension.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
# 4. the block counter (<plan-dir>/.stop_blocks) is below cap (PWF_GATE_CAP, default 20)
|
|
11
11
|
# 5. the ledger advanced since the last block (stall -> allow stop)
|
|
12
12
|
# When all hold, emits a single-line block-decision JSON on stdout and exits 0.
|
|
13
|
-
# Otherwise
|
|
13
|
+
# Otherwise reports incomplete plans and exits 0; completed plans stay silent
|
|
14
|
+
# with -Gate, including legacy plans without .mode. Explicit reports are unchanged.
|
|
14
15
|
#
|
|
15
16
|
# Stdin: read only when input is redirected ([Console]::IsInputRedirected), so an
|
|
16
17
|
# interactive console never blocks. Hook-piped JSON is EOF-terminated.
|
|
@@ -85,9 +86,10 @@ if ($TOTAL -eq 0) {
|
|
|
85
86
|
exit 0
|
|
86
87
|
}
|
|
87
88
|
|
|
88
|
-
#
|
|
89
|
+
# Keep explicit reports, but omit routine success from automatic gate checks.
|
|
89
90
|
function Write-AdvisoryReport {
|
|
90
91
|
if ($COMPLETE -eq $TOTAL -and $TOTAL -gt 0) {
|
|
92
|
+
if ($Gate) { return }
|
|
91
93
|
Write-Host ('[planning-with-files] ALL PHASES COMPLETE (' + $COMPLETE + '/' + $TOTAL + '). If the user has additional work, add new phases to task_plan.md before starting.')
|
|
92
94
|
} else {
|
|
93
95
|
Write-Host ('[planning-with-files] Task in progress (' + $COMPLETE + '/' + $TOTAL + ' phases complete). Update progress.md before stopping.')
|
|
@@ -21,8 +21,9 @@
|
|
|
21
21
|
# 4. the block counter (<plan-dir>/.stop_blocks) is below cap (PWF_GATE_CAP, default 20)
|
|
22
22
|
# 5. the ledger advanced since the last block (stall → allow stop)
|
|
23
23
|
# When all hold, it emits a single-line block-decision JSON on stdout and
|
|
24
|
-
# exits 0. Otherwise it
|
|
25
|
-
#
|
|
24
|
+
# exits 0. Otherwise it reports incomplete plans and exits 0; completed
|
|
25
|
+
# plans stay silent in --gate mode, including legacy plans without .mode.
|
|
26
|
+
# Without --gate, the explicit advisory report is unchanged.
|
|
26
27
|
#
|
|
27
28
|
# Stdin handling: the Claude Code Stop hook pipes a JSON payload on stdin. To
|
|
28
29
|
# avoid hanging when nothing is piped, stdin is read ONLY when fd 0 is not a
|
|
@@ -119,9 +120,11 @@ if [ "$TOTAL" -eq 0 ]; then
|
|
|
119
120
|
exit 0
|
|
120
121
|
fi
|
|
121
122
|
|
|
122
|
-
#
|
|
123
|
+
# Explicit status reports retain completion text. Automatic gate checks have
|
|
124
|
+
# nothing to report on success; keep evaluating all gate guards before here.
|
|
123
125
|
advisory_report() {
|
|
124
126
|
if [ "$COMPLETE" -eq "$TOTAL" ] && [ "$TOTAL" -gt 0 ]; then
|
|
127
|
+
[ "$GATE" -eq 1 ] && return 0
|
|
125
128
|
echo "[planning-with-files] ALL PHASES COMPLETE ($COMPLETE/$TOTAL). If the user has additional work, add new phases to task_plan.md before starting."
|
|
126
129
|
else
|
|
127
130
|
echo "[planning-with-files] Task in progress ($COMPLETE/$TOTAL phases complete). Update progress.md before stopping."
|
package/scripts/init-session.ps1
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# Initialize planning files for a new session
|
|
2
2
|
# Usage: .\init-session.ps1 [-Template TYPE] [project-name]
|
|
3
|
+
# .\init-session.ps1 -PlanDir # isolated plan with generated slug
|
|
3
4
|
# .\init-session.ps1 -Autonomous # v3 autonomous mode (opt-in)
|
|
4
5
|
# .\init-session.ps1 -Gated # v3 gated mode (opt-in, implies autonomous)
|
|
5
6
|
# Templates: default, analytics
|
|
@@ -12,6 +13,7 @@
|
|
|
12
13
|
param(
|
|
13
14
|
[string]$ProjectName = "project",
|
|
14
15
|
[string]$Template = "default",
|
|
16
|
+
[switch]$PlanDir,
|
|
15
17
|
[switch]$Autonomous,
|
|
16
18
|
[switch]$Gated
|
|
17
19
|
)
|
|
@@ -38,7 +40,45 @@ function Get-Nonce {
|
|
|
38
40
|
($bytes | ForEach-Object { $_.ToString("x2") }) -join ""
|
|
39
41
|
}
|
|
40
42
|
|
|
41
|
-
|
|
43
|
+
function Get-PlanSlug([string]$Name) {
|
|
44
|
+
# -creplace: the case-insensitive -replace lets letters that .NET folds to
|
|
45
|
+
# ASCII (a dotted capital I, the Kelvin sign) survive into the plan id.
|
|
46
|
+
$slug = $Name.ToLowerInvariant() -creplace '[^a-z0-9]', '-'
|
|
47
|
+
$slug = $slug -creplace '-{2,}', '-'
|
|
48
|
+
$slug = $slug.Trim('-')
|
|
49
|
+
if ($slug.Length -gt 40) {
|
|
50
|
+
$slug = $slug.Substring(0, 40).TrimEnd('-')
|
|
51
|
+
}
|
|
52
|
+
# The resolvers and the selector only accept ASCII plan ids.
|
|
53
|
+
if ($slug -cnotmatch '^[a-z0-9-]*$') {
|
|
54
|
+
return ""
|
|
55
|
+
}
|
|
56
|
+
return $slug
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function Get-ShortId {
|
|
60
|
+
return ([Guid]::NewGuid().ToString('N')).Substring(0, 8)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function Get-InheritedMode([string]$CurrentMode) {
|
|
64
|
+
$RootModePath = Join-Path (Get-Location).Path ".mode"
|
|
65
|
+
if (-not (Test-Path -LiteralPath $RootModePath)) {
|
|
66
|
+
return $CurrentMode
|
|
67
|
+
}
|
|
68
|
+
if ($CurrentMode -eq "gated") {
|
|
69
|
+
return $CurrentMode
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
$RootMode = Get-Content -LiteralPath $RootModePath -Raw -ErrorAction SilentlyContinue
|
|
73
|
+
# Case-sensitive like inherit_root_mode in init-session.sh and the injector.
|
|
74
|
+
if ($RootMode -cmatch 'gate') {
|
|
75
|
+
return "gated"
|
|
76
|
+
}
|
|
77
|
+
if ($RootMode -cmatch 'autonomous') {
|
|
78
|
+
return "autonomous"
|
|
79
|
+
}
|
|
80
|
+
return $CurrentMode
|
|
81
|
+
}
|
|
42
82
|
|
|
43
83
|
# Validate template
|
|
44
84
|
if ($Template -ne "default" -and $Template -ne "analytics") {
|
|
@@ -46,11 +86,98 @@ if ($Template -ne "default" -and $Template -ne "analytics") {
|
|
|
46
86
|
$Template = "default"
|
|
47
87
|
}
|
|
48
88
|
|
|
89
|
+
# Match init-session.sh: zero args (or an empty name) preserve legacy root
|
|
90
|
+
# mode. A positional project name or -PlanDir creates an isolated
|
|
91
|
+
# .planning/<date>-<slug>/ plan.
|
|
92
|
+
$NamedPlan = $PSBoundParameters.ContainsKey("ProjectName") -and -not [string]::IsNullOrEmpty($ProjectName)
|
|
93
|
+
$UsePlanDir = $PlanDir -or $NamedPlan
|
|
94
|
+
if ($UsePlanDir) {
|
|
95
|
+
$PlanningRoot = Join-Path (Get-Location).Path ".planning"
|
|
96
|
+
# Match init-session.sh: set-active-plan.ps1 owns every write to the
|
|
97
|
+
# shared pointer, so a named plan cannot be created without it.
|
|
98
|
+
$PlanSelector = Join-Path $ScriptDir "set-active-plan.ps1"
|
|
99
|
+
if (-not (Test-Path -LiteralPath $PlanSelector -PathType Leaf)) {
|
|
100
|
+
Write-Error "Error: set-active-plan.ps1 is required to create a named plan safely."
|
|
101
|
+
exit 1
|
|
102
|
+
}
|
|
103
|
+
New-Item -ItemType Directory -Path $PlanningRoot -Force | Out-Null
|
|
104
|
+
# Verify the physical planning root and the existing pointer before
|
|
105
|
+
# creating anything below it. A symlink or junction that escapes the
|
|
106
|
+
# project must not redirect init writes, and a linked or non-regular
|
|
107
|
+
# pointer must be refused before a plan directory exists on disk. A
|
|
108
|
+
# script that returns without exit leaves $LASTEXITCODE alone, so reset
|
|
109
|
+
# it first and treat a thrown error as a failure too.
|
|
110
|
+
$global:LASTEXITCODE = 0
|
|
111
|
+
try {
|
|
112
|
+
& $PlanSelector -VerifyRoot *> $null
|
|
113
|
+
} catch {
|
|
114
|
+
$global:LASTEXITCODE = 1
|
|
115
|
+
}
|
|
116
|
+
if ($LASTEXITCODE -ne 0) {
|
|
117
|
+
Write-Error "Error: the planning directory or the active plan pointer is outside the project or cannot be verified."
|
|
118
|
+
exit 1
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if ($NamedPlan) {
|
|
122
|
+
$Slug = Get-PlanSlug $ProjectName
|
|
123
|
+
} else {
|
|
124
|
+
$Slug = ""
|
|
125
|
+
$ProjectName = "untitled"
|
|
126
|
+
}
|
|
127
|
+
if ([string]::IsNullOrEmpty($Slug)) {
|
|
128
|
+
$Slug = "untitled-$(Get-ShortId)"
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
$BaseId = "$DATE-$Slug"
|
|
132
|
+
$PlanId = $BaseId
|
|
133
|
+
$Counter = 2
|
|
134
|
+
while (Test-Path -LiteralPath (Join-Path $PlanningRoot $PlanId)) {
|
|
135
|
+
$PlanId = "$BaseId-$Counter"
|
|
136
|
+
$Counter++
|
|
137
|
+
}
|
|
138
|
+
$TargetDir = Join-Path $PlanningRoot $PlanId
|
|
139
|
+
New-Item -ItemType Directory -Path $TargetDir -Force | Out-Null
|
|
140
|
+
# Reuse the selector's contained, atomic pointer replacement. Set-Content
|
|
141
|
+
# would follow a reparse point and truncate a hardlinked pointer in place,
|
|
142
|
+
# overwriting whichever file shares that inode.
|
|
143
|
+
$global:LASTEXITCODE = 0
|
|
144
|
+
try {
|
|
145
|
+
& $PlanSelector $PlanId *> $null
|
|
146
|
+
} catch {
|
|
147
|
+
$global:LASTEXITCODE = 1
|
|
148
|
+
}
|
|
149
|
+
if ($LASTEXITCODE -ne 0) {
|
|
150
|
+
Write-Error "Error: could not safely update the active plan pointer at $(Join-Path $PlanningRoot '.active_plan')."
|
|
151
|
+
exit 1
|
|
152
|
+
}
|
|
153
|
+
$Mode = Get-InheritedMode $Mode
|
|
154
|
+
} else {
|
|
155
|
+
$TargetDir = (Get-Location).Path
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
$TaskPlanPath = Join-Path $TargetDir "task_plan.md"
|
|
159
|
+
$FindingsPath = Join-Path $TargetDir "findings.md"
|
|
160
|
+
$ProgressPath = Join-Path $TargetDir "progress.md"
|
|
161
|
+
if ($UsePlanDir) {
|
|
162
|
+
$TaskPlanDisplay = $TaskPlanPath
|
|
163
|
+
$FindingsDisplay = $FindingsPath
|
|
164
|
+
$ProgressDisplay = $ProgressPath
|
|
165
|
+
} else {
|
|
166
|
+
$TaskPlanDisplay = "task_plan.md"
|
|
167
|
+
$FindingsDisplay = "findings.md"
|
|
168
|
+
$ProgressDisplay = "progress.md"
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
Write-Host "Initializing planning files for: $ProjectName (template: $Template)"
|
|
172
|
+
if ($UsePlanDir) {
|
|
173
|
+
Write-Host "PLAN_ID=$PlanId"
|
|
174
|
+
}
|
|
175
|
+
|
|
49
176
|
# Create task_plan.md if it doesn't exist
|
|
50
|
-
if (-not (Test-Path
|
|
177
|
+
if (-not (Test-Path -LiteralPath $TaskPlanPath)) {
|
|
51
178
|
$AnalyticsPlan = Join-Path $TemplateDir "analytics_task_plan.md"
|
|
52
179
|
if ($Template -eq "analytics" -and (Test-Path $AnalyticsPlan)) {
|
|
53
|
-
Copy-Item $AnalyticsPlan
|
|
180
|
+
Copy-Item -LiteralPath $AnalyticsPlan -Destination $TaskPlanPath
|
|
54
181
|
} else {
|
|
55
182
|
@"
|
|
56
183
|
# Task Plan: [Brief Description]
|
|
@@ -99,18 +226,18 @@ Phase 1
|
|
|
99
226
|
## Errors Encountered
|
|
100
227
|
| Error | Resolution |
|
|
101
228
|
|-------|------------|
|
|
102
|
-
"@ | Out-File -
|
|
229
|
+
"@ | Out-File -LiteralPath $TaskPlanPath -Encoding UTF8
|
|
103
230
|
}
|
|
104
|
-
Write-Host "Created
|
|
231
|
+
Write-Host "Created $TaskPlanDisplay"
|
|
105
232
|
} else {
|
|
106
|
-
Write-Host "
|
|
233
|
+
Write-Host "$TaskPlanDisplay already exists, skipping"
|
|
107
234
|
}
|
|
108
235
|
|
|
109
236
|
# Create findings.md if it doesn't exist
|
|
110
|
-
if (-not (Test-Path
|
|
237
|
+
if (-not (Test-Path -LiteralPath $FindingsPath)) {
|
|
111
238
|
$AnalyticsFindings = Join-Path $TemplateDir "analytics_findings.md"
|
|
112
239
|
if ($Template -eq "analytics" -and (Test-Path $AnalyticsFindings)) {
|
|
113
|
-
Copy-Item $AnalyticsFindings
|
|
240
|
+
Copy-Item -LiteralPath $AnalyticsFindings -Destination $FindingsPath
|
|
114
241
|
} else {
|
|
115
242
|
@"
|
|
116
243
|
# Findings & Decisions
|
|
@@ -131,15 +258,15 @@ if (-not (Test-Path "findings.md")) {
|
|
|
131
258
|
|
|
132
259
|
## Resources
|
|
133
260
|
-
|
|
134
|
-
"@ | Out-File -
|
|
261
|
+
"@ | Out-File -LiteralPath $FindingsPath -Encoding UTF8
|
|
135
262
|
}
|
|
136
|
-
Write-Host "Created
|
|
263
|
+
Write-Host "Created $FindingsDisplay"
|
|
137
264
|
} else {
|
|
138
|
-
Write-Host "
|
|
265
|
+
Write-Host "$FindingsDisplay already exists, skipping"
|
|
139
266
|
}
|
|
140
267
|
|
|
141
268
|
# Create progress.md if it doesn't exist
|
|
142
|
-
if (-not (Test-Path
|
|
269
|
+
if (-not (Test-Path -LiteralPath $ProgressPath)) {
|
|
143
270
|
if ($Template -eq "analytics") {
|
|
144
271
|
@"
|
|
145
272
|
# Progress Log
|
|
@@ -160,7 +287,7 @@ if (-not (Test-Path "progress.md")) {
|
|
|
160
287
|
### Errors
|
|
161
288
|
| Error | Resolution |
|
|
162
289
|
|-------|------------|
|
|
163
|
-
"@ | Out-File -
|
|
290
|
+
"@ | Out-File -LiteralPath $ProgressPath -Encoding UTF8
|
|
164
291
|
} else {
|
|
165
292
|
@"
|
|
166
293
|
# Progress Log
|
|
@@ -181,23 +308,28 @@ if (-not (Test-Path "progress.md")) {
|
|
|
181
308
|
### Errors
|
|
182
309
|
| Error | Resolution |
|
|
183
310
|
|-------|------------|
|
|
184
|
-
"@ | Out-File -
|
|
311
|
+
"@ | Out-File -LiteralPath $ProgressPath -Encoding UTF8
|
|
185
312
|
}
|
|
186
|
-
Write-Host "Created
|
|
313
|
+
Write-Host "Created $ProgressDisplay"
|
|
187
314
|
} else {
|
|
188
|
-
Write-Host "
|
|
315
|
+
Write-Host "$ProgressDisplay already exists, skipping"
|
|
189
316
|
}
|
|
190
317
|
|
|
191
318
|
Write-Host ""
|
|
192
319
|
Write-Host "Planning files initialized!"
|
|
193
|
-
|
|
320
|
+
if ($UsePlanDir) {
|
|
321
|
+
Write-Host "Active plan recorded: $(Join-Path $PlanningRoot '.active_plan')"
|
|
322
|
+
Write-Host "Pin this terminal to the plan for parallel sessions:"
|
|
323
|
+
Write-Host " `$env:PLAN_ID='$PlanId'"
|
|
324
|
+
} else {
|
|
325
|
+
Write-Host "Files: task_plan.md, findings.md, progress.md"
|
|
326
|
+
}
|
|
194
327
|
|
|
195
328
|
# v3 opt-in mode side effects. No-op when -Autonomous/-Gated were not passed, so
|
|
196
|
-
# the default path stays byte-equivalent to v2.43.0.
|
|
197
|
-
#
|
|
198
|
-
# .plan-attestation at the project root.
|
|
329
|
+
# the default path stays byte-equivalent to v2.43.0. Dotfiles live beside the
|
|
330
|
+
# selected plan; root mode therefore retains the legacy project-root behavior.
|
|
199
331
|
if ($Mode -ne "") {
|
|
200
|
-
$PlanDirPwf =
|
|
332
|
+
$PlanDirPwf = $TargetDir
|
|
201
333
|
|
|
202
334
|
# (a) reset gate block counter, drop stale gate ledger.
|
|
203
335
|
Set-Content -LiteralPath (Join-Path $PlanDirPwf ".stop_blocks") -Value "0" -Encoding ascii
|
|
@@ -216,13 +348,42 @@ if ($Mode -ne "") {
|
|
|
216
348
|
Set-Content -LiteralPath (Join-Path $PlanDirPwf ".mode") -Value $MarkerText -Encoding ascii
|
|
217
349
|
|
|
218
350
|
# (c) auto-attest (attestation default-on in v3 modes, security strand rec 1).
|
|
219
|
-
|
|
351
|
+
# attest-plan.ps1 intentionally refuses non-Windows hosts because its secure
|
|
352
|
+
# no-follow implementation uses Win32 handles. On Unix, use the POSIX
|
|
353
|
+
# attester instead. Bind slug mode to the plan we just created so an
|
|
354
|
+
# inherited PLAN_ID cannot redirect attestation to another plan.
|
|
220
355
|
$PlanFilePwf = Join-Path $PlanDirPwf "task_plan.md"
|
|
221
|
-
if (
|
|
356
|
+
if (Test-Path -LiteralPath $PlanFilePwf) {
|
|
357
|
+
$HadPlanId = Test-Path Env:PLAN_ID
|
|
358
|
+
$PreviousPlanId = $env:PLAN_ID
|
|
222
359
|
try {
|
|
223
|
-
|
|
360
|
+
if ($UsePlanDir) {
|
|
361
|
+
$env:PLAN_ID = $PlanId
|
|
362
|
+
} else {
|
|
363
|
+
Remove-Item Env:PLAN_ID -ErrorAction SilentlyContinue
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
$IsWindowsHost = [Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT
|
|
367
|
+
if ($IsWindowsHost) {
|
|
368
|
+
$AttestPs1 = Join-Path $ScriptDir "attest-plan.ps1"
|
|
369
|
+
if (Test-Path -LiteralPath $AttestPs1) {
|
|
370
|
+
& $AttestPs1 *> $null
|
|
371
|
+
}
|
|
372
|
+
} else {
|
|
373
|
+
$AttestSh = Join-Path $ScriptDir "attest-plan.sh"
|
|
374
|
+
$Sh = Get-Command sh -ErrorAction SilentlyContinue
|
|
375
|
+
if ($Sh -and (Test-Path -LiteralPath $AttestSh)) {
|
|
376
|
+
& $Sh.Path $AttestSh *> $null
|
|
377
|
+
}
|
|
378
|
+
}
|
|
224
379
|
} catch {
|
|
225
380
|
# attestation failure must not abort init; the mode marker still stands.
|
|
381
|
+
} finally {
|
|
382
|
+
if ($HadPlanId) {
|
|
383
|
+
$env:PLAN_ID = $PreviousPlanId
|
|
384
|
+
} else {
|
|
385
|
+
Remove-Item Env:PLAN_ID -ErrorAction SilentlyContinue
|
|
386
|
+
}
|
|
226
387
|
}
|
|
227
388
|
}
|
|
228
389
|
|
package/scripts/init-session.sh
CHANGED
|
@@ -83,7 +83,8 @@ done
|
|
|
83
83
|
|
|
84
84
|
DATE=$(date +%Y-%m-%d)
|
|
85
85
|
|
|
86
|
-
|
|
86
|
+
# CDPATH must not redirect the cd that locates the sibling scripts.
|
|
87
|
+
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
|
87
88
|
SKILL_ROOT="$(dirname "$SCRIPT_DIR")"
|
|
88
89
|
TEMPLATE_DIR="$SKILL_ROOT/templates"
|
|
89
90
|
|
|
@@ -382,6 +383,20 @@ if [ "$SLUG_MODE" -eq 1 ]; then
|
|
|
382
383
|
BASE_ID="${DATE}-${SLUG}"
|
|
383
384
|
PLAN_ID="$BASE_ID"
|
|
384
385
|
PLAN_ROOT="${PWD}/.planning"
|
|
386
|
+
PLAN_SELECTOR="${SCRIPT_DIR}/set-active-plan.sh"
|
|
387
|
+
if [ ! -f "${PLAN_SELECTOR}" ]; then
|
|
388
|
+
echo "Error: set-active-plan.sh is required to create a named plan safely." >&2
|
|
389
|
+
exit 1
|
|
390
|
+
fi
|
|
391
|
+
mkdir -p "${PLAN_ROOT}"
|
|
392
|
+
# Verify the physical planning root and the existing pointer before
|
|
393
|
+
# creating anything below it. A symlink or junction that escapes the
|
|
394
|
+
# project must not redirect init writes, and a linked or non-regular
|
|
395
|
+
# pointer must be refused before a plan directory exists on disk. The
|
|
396
|
+
# selector's check is constant time; --list would parse every plan.
|
|
397
|
+
if ! sh "${PLAN_SELECTOR}" --verify-root; then
|
|
398
|
+
exit 1
|
|
399
|
+
fi
|
|
385
400
|
counter=2
|
|
386
401
|
while [ -d "${PLAN_ROOT}/${PLAN_ID}" ]; do
|
|
387
402
|
PLAN_ID="${BASE_ID}-${counter}"
|
|
@@ -393,7 +408,13 @@ if [ "$SLUG_MODE" -eq 1 ]; then
|
|
|
393
408
|
echo "Initializing planning files for: ${PROJECT_NAME:-untitled} (template: $TEMPLATE)"
|
|
394
409
|
echo "PLAN_ID=$PLAN_ID"
|
|
395
410
|
create_files_in "$PLAN_DIR"
|
|
396
|
-
|
|
411
|
+
# Reuse the selector's contained, atomic pointer replacement. Direct shell
|
|
412
|
+
# redirection would truncate a pre-existing hardlink and could overwrite a
|
|
413
|
+
# different file that shares the same inode.
|
|
414
|
+
if ! sh "${PLAN_SELECTOR}" "${PLAN_ID}" >/dev/null; then
|
|
415
|
+
echo "Error: could not safely update ${PLAN_ROOT}/.active_plan." >&2
|
|
416
|
+
exit 1
|
|
417
|
+
fi
|
|
397
418
|
inherit_root_mode
|
|
398
419
|
apply_v3_mode "$PLAN_DIR" "${PLAN_DIR}/task_plan.md"
|
|
399
420
|
echo ""
|
|
@@ -407,9 +407,6 @@ def get_session_candidates(
|
|
|
407
407
|
return 'claude', []
|
|
408
408
|
|
|
409
409
|
|
|
410
|
-
PLANNING_LIKE_SQL = ('%task_plan.md', '%findings.md', '%progress.md')
|
|
411
|
-
|
|
412
|
-
|
|
413
410
|
def get_opencode_db_path() -> Optional[Path]:
|
|
414
411
|
"""Resolve OpenCode SQLite path. Same on all OS per xdg-basedir."""
|
|
415
412
|
xdg = os.environ.get('XDG_DATA_HOME')
|
|
@@ -576,28 +573,42 @@ def opencode_catchup(project_path: str, mode: str = 'no-history') -> None:
|
|
|
576
573
|
update_time = None
|
|
577
574
|
update_idx = -1
|
|
578
575
|
for idx, (sid, _) in enumerate(previous_sessions):
|
|
579
|
-
params = (sid,) + PLANNING_LIKE_SQL
|
|
580
576
|
cur.execute(
|
|
581
577
|
"""
|
|
582
|
-
SELECT time_created FROM part
|
|
578
|
+
SELECT time_created, data FROM part
|
|
583
579
|
WHERE session_id = ?
|
|
584
580
|
AND json_extract(data, '$.type') = 'tool'
|
|
585
581
|
AND lower(json_extract(data, '$.tool')) IN ('write', 'edit', 'patch')
|
|
586
582
|
AND (
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
583
|
+
replace(json_extract(data, '$.state.input.filePath'), char(92), '/')
|
|
584
|
+
IN ('task_plan.md', 'findings.md', 'progress.md')
|
|
585
|
+
OR replace(json_extract(data, '$.state.input.filePath'), char(92), '/')
|
|
586
|
+
GLOB '*/task_plan.md'
|
|
587
|
+
OR replace(json_extract(data, '$.state.input.filePath'), char(92), '/')
|
|
588
|
+
GLOB '*/findings.md'
|
|
589
|
+
OR replace(json_extract(data, '$.state.input.filePath'), char(92), '/')
|
|
590
|
+
GLOB '*/progress.md'
|
|
590
591
|
)
|
|
591
|
-
ORDER BY time_created DESC
|
|
592
|
-
LIMIT 1
|
|
592
|
+
ORDER BY time_created DESC, id DESC
|
|
593
593
|
""",
|
|
594
|
-
|
|
594
|
+
(sid,),
|
|
595
595
|
)
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
596
|
+
# Iterate lazily: write parts carry whole file bodies, and fetchall
|
|
597
|
+
# would materialize every planning write of the session before the
|
|
598
|
+
# first validated row ends the loop.
|
|
599
|
+
for candidate_time, data_str in cur:
|
|
600
|
+
data = json_loads(data_str)
|
|
601
|
+
if not isinstance(data, dict):
|
|
602
|
+
continue
|
|
603
|
+
state = data.get('state')
|
|
604
|
+
input_ = state.get('input') if isinstance(state, dict) else None
|
|
605
|
+
file_path = input_.get('filePath') if isinstance(input_, dict) else None
|
|
606
|
+
if planning_file_from_path(file_path):
|
|
607
|
+
update_sid = sid
|
|
608
|
+
update_time = candidate_time
|
|
609
|
+
update_idx = idx
|
|
610
|
+
break
|
|
611
|
+
if update_sid:
|
|
601
612
|
break
|
|
602
613
|
|
|
603
614
|
if not update_sid:
|
|
@@ -685,12 +696,17 @@ def parse_session_messages(session_file: Path) -> List[Dict[str, Any]]:
|
|
|
685
696
|
|
|
686
697
|
|
|
687
698
|
def planning_file_from_path(path_value: Any) -> Optional[str]:
|
|
699
|
+
"""Return a planning filename only when it is the path's exact basename.
|
|
700
|
+
|
|
701
|
+
A suffix check treats lookalikes such as ``draft_task_plan.md`` as real
|
|
702
|
+
planning updates and can anchor catchup at unrelated transcript content.
|
|
703
|
+
Normalize separators so the same boundary rule works for Unix and Windows
|
|
704
|
+
session records.
|
|
705
|
+
"""
|
|
688
706
|
if not isinstance(path_value, str):
|
|
689
707
|
return None
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
return pf
|
|
693
|
-
return None
|
|
708
|
+
basename = path_value.replace(chr(92), '/').rsplit('/', 1)[-1]
|
|
709
|
+
return basename if basename in PLANNING_FILES else None
|
|
694
710
|
|
|
695
711
|
|
|
696
712
|
def planning_file_from_paths(paths: Iterable[Any]) -> Optional[str]:
|