planning-with-files 3.18.3 → 3.20.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +83 -83
- package/package.json +1 -1
- package/scripts/attest-plan.ps1 +10 -2
- package/scripts/init-session.ps1 +267 -26
- package/scripts/init-session.sh +85 -10
- 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 +44 -23
- package/scripts/set-active-plan.ps1 +38 -3
- package/scripts/set-active-plan.sh +44 -9
- package/templates/analytics_findings.md +67 -67
- package/templates/analytics_task_plan.md +7 -1
- package/templates/findings.md +47 -47
- package/templates/progress.md +58 -58
- package/templates/task_plan.md +89 -89
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
|
|
|
@@ -102,6 +103,7 @@ slugify() {
|
|
|
102
103
|
# Lowercase, non-alphanumerics → '-', collapse repeats, trim leading/trailing '-'
|
|
103
104
|
printf '%s' "$1" \
|
|
104
105
|
| tr '[:upper:]' '[:lower:]' \
|
|
106
|
+
| tr '\r\n' '--' \
|
|
105
107
|
| sed -e 's/[^a-z0-9]/-/g' -e 's/-\{2,\}/-/g' -e 's/^-//' -e 's/-$//' \
|
|
106
108
|
| cut -c1-40
|
|
107
109
|
}
|
|
@@ -184,6 +186,10 @@ apply_v3_mode() {
|
|
|
184
186
|
_mode_plan="$2"
|
|
185
187
|
[ -z "$MODE" ] && return 0
|
|
186
188
|
|
|
189
|
+
ATTESTATION_OK=0
|
|
190
|
+
ATTESTATION_COMMAND="attest-plan.sh"
|
|
191
|
+
ATTESTATION_REASON="task_plan.md was not available for attestation"
|
|
192
|
+
|
|
187
193
|
# (a) reset the gate block counter and drop any stale gate ledger so a prior
|
|
188
194
|
# run's high block count cannot let the next run stop instantly.
|
|
189
195
|
printf '0\n' > "${_mode_dir}/.stop_blocks"
|
|
@@ -201,12 +207,61 @@ apply_v3_mode() {
|
|
|
201
207
|
|
|
202
208
|
# (c) auto-attest the plan (attestation default-on in v3 modes, security
|
|
203
209
|
# strand rec 1). attest-plan.sh resolves the same way init-session just
|
|
204
|
-
# pinned things
|
|
205
|
-
#
|
|
206
|
-
#
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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}"
|
|
210
265
|
fi
|
|
211
266
|
}
|
|
212
267
|
|
|
@@ -382,6 +437,20 @@ if [ "$SLUG_MODE" -eq 1 ]; then
|
|
|
382
437
|
BASE_ID="${DATE}-${SLUG}"
|
|
383
438
|
PLAN_ID="$BASE_ID"
|
|
384
439
|
PLAN_ROOT="${PWD}/.planning"
|
|
440
|
+
PLAN_SELECTOR="${SCRIPT_DIR}/set-active-plan.sh"
|
|
441
|
+
if [ ! -f "${PLAN_SELECTOR}" ]; then
|
|
442
|
+
echo "Error: set-active-plan.sh is required to create a named plan safely." >&2
|
|
443
|
+
exit 1
|
|
444
|
+
fi
|
|
445
|
+
mkdir -p "${PLAN_ROOT}"
|
|
446
|
+
# Verify the physical planning root and the existing pointer before
|
|
447
|
+
# creating anything below it. A symlink or junction that escapes the
|
|
448
|
+
# project must not redirect init writes, and a linked or non-regular
|
|
449
|
+
# pointer must be refused before a plan directory exists on disk. The
|
|
450
|
+
# selector's check is constant time; --list would parse every plan.
|
|
451
|
+
if ! sh "${PLAN_SELECTOR}" --verify-root; then
|
|
452
|
+
exit 1
|
|
453
|
+
fi
|
|
385
454
|
counter=2
|
|
386
455
|
while [ -d "${PLAN_ROOT}/${PLAN_ID}" ]; do
|
|
387
456
|
PLAN_ID="${BASE_ID}-${counter}"
|
|
@@ -393,7 +462,13 @@ if [ "$SLUG_MODE" -eq 1 ]; then
|
|
|
393
462
|
echo "Initializing planning files for: ${PROJECT_NAME:-untitled} (template: $TEMPLATE)"
|
|
394
463
|
echo "PLAN_ID=$PLAN_ID"
|
|
395
464
|
create_files_in "$PLAN_DIR"
|
|
396
|
-
|
|
465
|
+
# Reuse the selector's contained, atomic pointer replacement. Direct shell
|
|
466
|
+
# redirection would truncate a pre-existing hardlink and could overwrite a
|
|
467
|
+
# different file that shares the same inode.
|
|
468
|
+
if ! sh "${PLAN_SELECTOR}" "${PLAN_ID}" >/dev/null; then
|
|
469
|
+
echo "Error: could not safely update ${PLAN_ROOT}/.active_plan." >&2
|
|
470
|
+
exit 1
|
|
471
|
+
fi
|
|
397
472
|
inherit_root_mode
|
|
398
473
|
apply_v3_mode "$PLAN_DIR" "${PLAN_DIR}/task_plan.md"
|
|
399
474
|
echo ""
|
|
@@ -401,7 +476,7 @@ if [ "$SLUG_MODE" -eq 1 ]; then
|
|
|
401
476
|
echo "Pin this terminal to the plan for parallel sessions:"
|
|
402
477
|
echo " export PLAN_ID=$PLAN_ID"
|
|
403
478
|
if [ -n "$MODE" ]; then
|
|
404
|
-
|
|
479
|
+
print_v3_mode_status "${PLAN_DIR}"
|
|
405
480
|
fi
|
|
406
481
|
else
|
|
407
482
|
PROJECT_NAME="${PROJECT_NAME:-project}"
|
|
@@ -412,6 +487,6 @@ else
|
|
|
412
487
|
echo "Planning files initialized!"
|
|
413
488
|
echo "Files: task_plan.md, findings.md, progress.md"
|
|
414
489
|
if [ -n "$MODE" ]; then
|
|
415
|
-
|
|
490
|
+
print_v3_mode_status "$(pwd)"
|
|
416
491
|
fi
|
|
417
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
|
|
@@ -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')
|
|
@@ -471,13 +468,16 @@ def _opencode_state_annotation(state: Any) -> str:
|
|
|
471
468
|
|
|
472
469
|
def _format_opencode_part(data: Dict[str, Any], session_id: str) -> Optional[Dict[str, Any]]:
|
|
473
470
|
"""Print-ready summary for one OpenCode part row."""
|
|
471
|
+
if not isinstance(data, dict):
|
|
472
|
+
return None
|
|
474
473
|
ptype = data.get('type')
|
|
475
474
|
short = safe_session_label(session_id)
|
|
476
475
|
if ptype == 'tool':
|
|
477
|
-
|
|
476
|
+
tool_value = data.get('tool')
|
|
477
|
+
tool = tool_value.lower() if isinstance(tool_value, str) else ''
|
|
478
478
|
state = data.get('state') or {}
|
|
479
479
|
input_ = state.get('input') if isinstance(state, dict) else None
|
|
480
|
-
input_ = input_
|
|
480
|
+
input_ = input_ if isinstance(input_, dict) else {}
|
|
481
481
|
outcome = _opencode_state_annotation(state)
|
|
482
482
|
if tool in ('write', 'edit'):
|
|
483
483
|
fp = input_.get('filePath', '')
|
|
@@ -489,7 +489,8 @@ def _format_opencode_part(data: Dict[str, Any], session_id: str) -> Optional[Dic
|
|
|
489
489
|
return {'session': short, 'summary': f"Tool bash: {cmd}{outcome}"}
|
|
490
490
|
return {'session': short, 'summary': f"Tool {tool}{outcome}"}
|
|
491
491
|
if ptype == 'text':
|
|
492
|
-
|
|
492
|
+
text_value = data.get('text')
|
|
493
|
+
text = text_value[:300] if isinstance(text_value, str) else ''
|
|
493
494
|
if text.strip():
|
|
494
495
|
return {'session': short, 'summary': f"text: {text}"}
|
|
495
496
|
return None
|
|
@@ -576,28 +577,43 @@ def opencode_catchup(project_path: str, mode: str = 'no-history') -> None:
|
|
|
576
577
|
update_time = None
|
|
577
578
|
update_idx = -1
|
|
578
579
|
for idx, (sid, _) in enumerate(previous_sessions):
|
|
579
|
-
params = (sid,) + PLANNING_LIKE_SQL
|
|
580
580
|
cur.execute(
|
|
581
581
|
"""
|
|
582
|
-
SELECT time_created FROM part
|
|
582
|
+
SELECT time_created, data FROM part
|
|
583
583
|
WHERE session_id = ?
|
|
584
|
+
AND json_valid(data)
|
|
584
585
|
AND json_extract(data, '$.type') = 'tool'
|
|
585
586
|
AND lower(json_extract(data, '$.tool')) IN ('write', 'edit', 'patch')
|
|
586
587
|
AND (
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
588
|
+
replace(json_extract(data, '$.state.input.filePath'), char(92), '/')
|
|
589
|
+
IN ('task_plan.md', 'findings.md', 'progress.md')
|
|
590
|
+
OR replace(json_extract(data, '$.state.input.filePath'), char(92), '/')
|
|
591
|
+
GLOB '*/task_plan.md'
|
|
592
|
+
OR replace(json_extract(data, '$.state.input.filePath'), char(92), '/')
|
|
593
|
+
GLOB '*/findings.md'
|
|
594
|
+
OR replace(json_extract(data, '$.state.input.filePath'), char(92), '/')
|
|
595
|
+
GLOB '*/progress.md'
|
|
590
596
|
)
|
|
591
|
-
ORDER BY time_created DESC
|
|
592
|
-
LIMIT 1
|
|
597
|
+
ORDER BY time_created DESC, id DESC
|
|
593
598
|
""",
|
|
594
|
-
|
|
599
|
+
(sid,),
|
|
595
600
|
)
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
+
# Iterate lazily: write parts carry whole file bodies, and fetchall
|
|
602
|
+
# would materialize every planning write of the session before the
|
|
603
|
+
# first validated row ends the loop.
|
|
604
|
+
for candidate_time, data_str in cur:
|
|
605
|
+
data = json_loads(data_str)
|
|
606
|
+
if not isinstance(data, dict):
|
|
607
|
+
continue
|
|
608
|
+
state = data.get('state')
|
|
609
|
+
input_ = state.get('input') if isinstance(state, dict) else None
|
|
610
|
+
file_path = input_.get('filePath') if isinstance(input_, dict) else None
|
|
611
|
+
if planning_file_from_path(file_path):
|
|
612
|
+
update_sid = sid
|
|
613
|
+
update_time = candidate_time
|
|
614
|
+
update_idx = idx
|
|
615
|
+
break
|
|
616
|
+
if update_sid:
|
|
601
617
|
break
|
|
602
618
|
|
|
603
619
|
if not update_sid:
|
|
@@ -685,12 +701,17 @@ def parse_session_messages(session_file: Path) -> List[Dict[str, Any]]:
|
|
|
685
701
|
|
|
686
702
|
|
|
687
703
|
def planning_file_from_path(path_value: Any) -> Optional[str]:
|
|
704
|
+
"""Return a planning filename only when it is the path's exact basename.
|
|
705
|
+
|
|
706
|
+
A suffix check treats lookalikes such as ``draft_task_plan.md`` as real
|
|
707
|
+
planning updates and can anchor catchup at unrelated transcript content.
|
|
708
|
+
Normalize separators so the same boundary rule works for Unix and Windows
|
|
709
|
+
session records.
|
|
710
|
+
"""
|
|
688
711
|
if not isinstance(path_value, str):
|
|
689
712
|
return None
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
return pf
|
|
693
|
-
return None
|
|
713
|
+
basename = path_value.replace(chr(92), '/').rsplit('/', 1)[-1]
|
|
714
|
+
return basename if basename in PLANNING_FILES else None
|
|
694
715
|
|
|
695
716
|
|
|
696
717
|
def planning_file_from_paths(paths: Iterable[Any]) -> Optional[str]:
|
|
@@ -5,12 +5,16 @@
|
|
|
5
5
|
# .\set-active-plan.ps1 - print the current active plan (if any)
|
|
6
6
|
# .\set-active-plan.ps1 -List - list available named plans and phase counts
|
|
7
7
|
# .\set-active-plan.ps1 --list - equivalent to -List and -l
|
|
8
|
+
# .\set-active-plan.ps1 -VerifyRoot - check the planning root and pointer only
|
|
9
|
+
# .\set-active-plan.ps1 --verify-root - equivalent to -VerifyRoot
|
|
8
10
|
|
|
9
11
|
param(
|
|
10
12
|
[Parameter(Position = 0)]
|
|
11
13
|
[string]$PlanId = "",
|
|
12
14
|
[Alias('l', '-list')]
|
|
13
15
|
[switch]$List,
|
|
16
|
+
[Alias('verify-root')]
|
|
17
|
+
[switch]$VerifyRoot,
|
|
14
18
|
[Alias('h', '-help')]
|
|
15
19
|
[switch]$Help
|
|
16
20
|
)
|
|
@@ -117,9 +121,13 @@ function Test-SafeActiveFile {
|
|
|
117
121
|
param([switch]$AllowLink)
|
|
118
122
|
$item = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
|
|
119
123
|
if (-not $item) { return $false }
|
|
120
|
-
# 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')
|
|
121
129
|
return -not $item.PSIsContainer -and
|
|
122
|
-
($AllowLink -or
|
|
130
|
+
($AllowLink -or -not $linked) -and
|
|
123
131
|
(Test-WithinRoot $ActiveFile)
|
|
124
132
|
}
|
|
125
133
|
|
|
@@ -200,6 +208,8 @@ function Show-PlanList {
|
|
|
200
208
|
Write-Output "[active] marks the shared .active_plan pointer; listing does not bind this session."
|
|
201
209
|
$found = $false
|
|
202
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 }
|
|
203
213
|
if (-not (Test-ValidSlug $plan.Name) -or -not (Test-WithinRoot $plan.FullName)) { continue }
|
|
204
214
|
$planFile = Join-Path $plan.FullName "task_plan.md"
|
|
205
215
|
if (-not (Test-Path -LiteralPath $planFile -PathType Leaf) -or -not (Test-WithinRoot $planFile)) { continue }
|
|
@@ -213,7 +223,27 @@ function Show-PlanList {
|
|
|
213
223
|
}
|
|
214
224
|
|
|
215
225
|
if ($Help -or $PlanId -eq "--help" -or $PlanId -eq "-h") {
|
|
216
|
-
Write-Output "Usage: set-active-plan.ps1 [-List|-l|--list|PLAN_ID]"
|
|
226
|
+
Write-Output "Usage: set-active-plan.ps1 [-List|-l|--list|-VerifyRoot|PLAN_ID]"
|
|
227
|
+
exit 0
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
# Constant-time check for callers that are about to create a plan: the
|
|
231
|
+
# planning root, when present, must be inside the project, and an existing
|
|
232
|
+
# pointer must be replaceable. Nothing is read, listed, or written.
|
|
233
|
+
if ($VerifyRoot) {
|
|
234
|
+
if ($List -or $PlanId) {
|
|
235
|
+
Write-Error "Error: verify the planning root in a separate call."
|
|
236
|
+
exit 1
|
|
237
|
+
}
|
|
238
|
+
if ((Test-Path -LiteralPath $PlanRoot -PathType Container) -and -not (Test-WithinRoot $PlanRoot)) {
|
|
239
|
+
Write-Error "Error: planning directory is outside the project or cannot be verified."
|
|
240
|
+
exit 1
|
|
241
|
+
}
|
|
242
|
+
$existingPointer = Get-Item -LiteralPath $ActiveFile -Force -ErrorAction SilentlyContinue
|
|
243
|
+
if ($existingPointer -and -not (Test-SafeActiveFile)) {
|
|
244
|
+
Write-Error "Error: the active plan pointer must be a regular file within the project."
|
|
245
|
+
exit 1
|
|
246
|
+
}
|
|
217
247
|
exit 0
|
|
218
248
|
}
|
|
219
249
|
|
|
@@ -257,6 +287,11 @@ if (-not (Test-Path -LiteralPath $PlanDir -PathType Container)) {
|
|
|
257
287
|
Write-Error "Run: init-session.sh `"$PlanId`" to create it, or check .planning\ for available plans."
|
|
258
288
|
exit 1
|
|
259
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
|
+
}
|
|
260
295
|
if (-not (Test-WithinRoot $PlanRoot) -or -not (Test-WithinRoot $PlanDir)) {
|
|
261
296
|
Write-Error "Error: plan directory must remain within the project."
|
|
262
297
|
exit 1
|